diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66ab893..1a3d665 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,6 +91,15 @@ jobs: # setup-java left in JAVA_HOME: RetroFuturaGradle and ForgeGradle run game tooling # inside the Gradle process itself, so the daemon's JVM is part of the build # definition. `-D` on the command line overrides the project's gradle.properties. + # The odd one out: RetroFuturaGradle covers only 1.7.10 and 1.12.2, so 1.8.9 has to + # use the era-correct ForgeGradle 2.1 — which needs Gradle 2.14 on Java 8. That is + # why this adapter carries its own wrapper and ignores the pinned Gradle above. + - name: Build forge-1.8.9 + working-directory: mod/adapters/forge-1.8.9 + env: + JAVA_HOME: ${{ env.JAVA_HOME_8_X64 }} + run: ./gradlew build --no-daemon + - name: Build forge-1.12.2 working-directory: mod/adapters/forge-1.12.2 run: gradle build ${{ env.TOOLCHAINS }} -Dorg.gradle.java.home=$JAVA_HOME_17_X64 diff --git a/.gitignore b/.gitignore index 057a7c2..025d232 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,20 @@ release/ launcher/build/ launcher/resources/ +# tsc's incremental cache. It changes on every typecheck and describes one machine's last +# build, so committing it makes every branch conflict over a file nobody reads. +*.tsbuildinfo + # Java / Gradle build/ .gradle/ bin/ *.class *.jar -!gradle/wrapper/gradle-wrapper.jar +# Anchored with `**/` so it reaches an adapter's own wrapper, not just a root one. The +# 1.8.9 adapter is the only build here that needs a wrapper — without its jar committed, a +# fresh clone and CI both fail at `./gradlew`. +!**/gradle/wrapper/gradle-wrapper.jar # Forge / Minecraft dev run/ diff --git a/README.md b/README.md index 190a78b..75ca776 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,27 @@ Targets **Minecraft 1.8.9 → 26.2** on Forge. ## Status -Early but working end to end in code: the launcher builds and runs, both adapter jars -compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests pass. +Early but working end to end in code: the launcher builds and runs, all three built adapter +jars compile against real Forge toolchains, and 304 launcher tests plus 23 Java tests pass. + +Ella opens on a five-step guide — install a version, create a project, add a block, point +at Blockbench, launch — where every step reads its own state rather than being ticked, so +it can never claim something is done when it is not, and a step that stops being true goes +back to undone on its own. Once all five pass, the guide folds away and the same space +becomes a dashboard. Every control that is disabled says in its tooltip why, because a +greyed-out button with no reason reads as a broken app rather than a missing prerequisite. + +**A project is bound to the version it was authored for.** Opening it preselects that +version, and launching a different one stops first to say what the change would do — listed +against the project's own files, entry by entry, with an offer to rewrite the ones Ella can. +The two divergences that matter are both silent in game: a `parent` overrides a model's own +geometry below 1.9, and vanilla's texture folders were renamed in 1.13. Finding either from +a black block in the world is exactly what that dialog exists to prevent. + +**Changes that touch files can be taken back from the notification that reports them** — a +deletion, a rename, a texture variable removed, a model Ella rewrote. Files a deletion took +wait in a per-session stash rather than being destroyed, so the offer is real rather than a +promise the disk cannot keep. | Component | State | |---|---| @@ -22,7 +41,12 @@ compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests | Adapter presence check on every launch | done | | Version install / uninstall | done | | Quick launch from any view | done | +| Guided setup — five steps read off live state | done | +| Animated splash while the launcher starts | done | | Project and entry deletion | done | +| Undo a change from its notification | done | +| Projects bound to a Minecraft version, with model migration | done | +| Turntable previews of blocks and items | done | | Block/item editor with capability gating | done | | Blockbench open + file watch | done | | Blockbench live-sync plugin | done | @@ -30,8 +54,10 @@ compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests | Windows installer + portable `.exe` | done — built and smoke-tested | | GitHub Actions build & release workflow | written, not yet run against a remote | | Forge adapter 1.12.2 (`[1.12, 1.13)`) | **verified in game** — blocks render in hand and placed | +| Forge adapter 1.8.9 (`[1.8.8, 1.9)`) | **verified in game** — mod loads, blocks register and place | | Forge adapter 1.21.1 (`[1.21.1, 1.21.2)`) | builds and loads; in-game rendering not yet confirmed | -| Forge adapters 1.8.9 and 1.16.5–1.20.1 | not started | +| Forge adapter 1.16.5–1.20.1 | not started | +| Minecraft 1.7.10 and older | **not possible** — see below | | OBJ model source | designed for, not implemented | The full loop works on 1.12.2: the launcher installs Minecraft and Forge, picks the right @@ -43,6 +69,16 @@ in [`docs/building.md`](docs/building.md) and the cross-version traps in [`docs/architecture.md`](docs/architecture.md). Six of the seven were invisible to the compiler and only surfaced in a running game. +**1.7.10 and older cannot be supported, and the reason is not effort.** The JSON model +system arrived in 1.8: the 1.7.10 client jar contains zero model and blockstate files +against 1.8.9's 1935, and its `assets/minecraft/` holds only fonts, languages, shaders, +texts and textures. Block shape on 1.7.10 lives in Java code and item appearance in +atlas sprites, so there is no file for Blockbench to edit and no file for a save to +update. Ella could only fake it by reimplementing a model interpreter inside the mod — +which is precisely the "renders something close to what the file says" failure this +design exists to avoid. Texture-only live editing on plain cubes would be possible; ask +if that is worth having. + **Version coverage is narrow and deliberately honest.** An adapter claims only the versions it was compiled against, because Forge changed the block properties and item component APIs inside every bucket — "compiles for 1.21.1" says nothing about 1.21.11. diff --git a/docs/architecture.md b/docs/architecture.md index b29803e..cd598a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,6 +151,73 @@ that merge are easy to get wrong, and both fail far from their cause: Both mistakes share a shape worth remembering: a modded version id looks enough like a version to pass through code unnoticed, and fails somewhere else entirely. +## A parent overrides the child's geometry on 1.8.x + +`ModelBlock.getElements()` on 1.8.9 is, in full: + +```java +return this.hasParent() ? this.parent.getElements() : this.elements; +``` + +with `hasParent()` being nothing more than `parent != null`. The parent wins outright. That +is why vanilla 1.8.9's own `block/cube.json` declares **no** parent and inlines its +elements — it only gained `"parent": "block/block"` in 1.9, once the semantics flipped to +"the child's elements win if it has any". + +Blockbench keeps whatever parent it finds. So a model that starts life as +`{ parent: "block/cube_all" }` and then gains geometry carries that parent forever, and on +1.8.x renders as a plain cube — in the missing-texture checkerboard, because Blockbench +also rewrites the texture keys and the parent's `#all` stops resolving. The author sees a +magenta cube and reasonably concludes Ella lost their model. It loaded and was overruled. + +Two changes, because one alone is not enough: + +- The generated starting model is **self-contained** — its own full-cube elements, `all` + and `particle` declared, no parent. There is then no parent for Blockbench to keep. It + also has to declare `particle` explicitly, which `block/cube_all` used to supply for free. +- Models that already carry the trap are detected from the parsed model the editor already + holds, and the warning offers to drop the `parent`. Dropping it is the whole fix and + costs nothing: from 1.9 onwards that parent's geometry was being ignored anyway. + +Ella does not rewrite the author's file on its own. Saving in Blockbench has to stay the +only thing that changes it, so the fix is a button rather than a repair on load. + +## Two generations of Forge installer + +Ella runs the official Forge installer headlessly rather than reimplementing it. From 1.13 +onwards that is not a preference: installation runs binary-patch and deobfuscation +processors, and reproducing those would mean tracking a toolchain that is not ours. + +It stops being possible going the other way. `--installClient` was added to the installer +around 2018; older builds abort with *"'installClient' is not a recognized option"*. That +covers every Forge build for 1.8.x — so the whole 1.8 line installed as vanilla, and +because a Forge failure is collected as a warning rather than thrown, the version still +appeared installed. The symptom was "I have no Forge for 1.8.8", a long way from the cause. + +Those same builds predate the processors, so their install genuinely is just unpacking: +write `versionInfo` from `install_profile.json` as the version json — it carries +`inheritsFrom`, so the vanilla document supplies the rest — drop the universal jar at the +path `install.path` names inside `libraries/`, and download the twenty-odd libraries the +version file lists. + +That last step is the one to get wrong, because the official installer does it invisibly. +Skip it and the game dies before its window opens with +`NoClassDefFoundError: org/objectweb/asm/ClassVisitor`: ASM is listed in the 1.8.x Forge +file with no download url at all, so nothing else in the pipeline would ever fetch it. The +launcher already knows how to resolve the three shapes a library entry can take — a direct +url, a repository base, or nothing but a coordinate — so the legacy path reuses that code +rather than carrying a second copy of it. + +A missing jar does not stop Java from starting, which is why `buildLaunchCommand` now +refuses to launch with an incomplete classpath. Checking costs a few stat calls and turns +a stack trace naming a *class* into one sentence naming the *file* and the fix. + +Which path applies is read from the installer's own profile, not from the Minecraft +version: the old generation carries a `versionInfo` block, the new one carries +`processors`. The change came with an installer release rather than a game release, and +1.12.2 sits on the new side of it while 1.8.9 sits on the old one — a version-number rule +would have put them the wrong way round. + ## Install and uninstall Downloaded content falls into three groups, and the difference decides what an uninstall @@ -188,6 +255,52 @@ version cannot honour and says why. One UI serves the whole range honestly. See [`protocol.md`](protocol.md) and [`project-format.md`](project-format.md). +## Guided setup, derived rather than stored + +Ella's loop needs five things true at once — a version installed, a project open, an entry +in it, Blockbench reachable, and the mod connected — and the order is not guessable from +the navigation. The home view walks them as an ordered list. + +Nothing about that list is persisted. `shared/workflow.ts` takes a flat snapshot of the +same state the rest of the UI reads and returns which steps are done and which one is +current; there is no "onboarding completed" flag anywhere. Two things fall out of that: +a step cannot claim to be done when it is not, and a step that stops being true — the game +exits, the project is closed — reopens on its own without anything having to invalidate it. +It is also the reason the checklist is safe to keep showing after setup is complete, where +it collapses into a toggle rather than disappearing. + +The same principle covers disabled controls. Every one of them carries the reason in its +`title` — *launch the game first*, *Java 21 was not found*, *stop the running game* — +because the three states that dim the give/place buttons have three different fixes, and a +greyed-out button with no reason reads as a broken app rather than a missing prerequisite. + +Whether Blockbench is reachable is asked of the main process (`blockbench:resolve`) rather +than inferred from the configured path, since the usual case is an empty setting and a +successful auto-detection. + +## Two write paths for the slot namespace + +`writeSlotNamespace` wipes and regenerates everything: four files per block slot plus one +per item slot, so a default pool of 128 + 128 is 640 files, about 450 ms. That is the right +answer for a change that moves bindings, renames the namespace or deletes an entry, because +a stale blockstate left behind keeps rendering a ghost. + +It is the wrong answer for a settings change, which arrives once per slider tick. Settings +reach the pack through exactly one thing — the render layer, baked into that slot's +redirect model — so `writeEntrySlot` rewrites that slot alone, four files and about 2 ms. +`entries:patchLive` takes that path. + +Two related rules follow from the same reasoning: + +- The `ella` namespace is excluded from the file watcher. It is Ella's own output, and + watching it meant every settings change fed its own writes back in as a model change: + a reload the game did not need, plus a preview refresh in the editor for each one. +- The editor's settings form is driven by a local draft, not by the saved project. A + control bound to the round trip cannot follow the mouse. Writes are coalesced behind + the draft, one at a time, and a spinner reports that they are in flight — the controls + are never disabled while saving, since that would reintroduce exactly the stall the + draft exists to remove. + ## Language policy Code, comments, identifiers and documentation are English. Every user-facing string is diff --git a/docs/building.md b/docs/building.md index fef8ec1..cd13313 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,6 +1,6 @@ # Building Ella -Three independent builds. Nothing is shared through a repository — the adapters compile +Four independent builds. Nothing is shared through a repository — the adapters compile `ella-core` from source — so they can be built in any order. ## Launcher @@ -8,7 +8,7 @@ Three independent builds. Nothing is shared through a repository — the adapter ```bash cd launcher npm install -npm test # 202 tests, runs straight off the TypeScript sources +npm test # 232 tests, runs straight off the TypeScript sources npm run typecheck npm run dev # Electron with hot reload npm run build # production bundle into out/ @@ -38,7 +38,11 @@ instance's `mods` folder. ```bash cd mod/adapters/forge-1.12.2 && gradle build -cd mod/adapters/forge-modern && gradle build +cd mod/adapters/forge-modern && gradle build + +# The 1.8.9 adapter carries its own wrapper and needs a Java 8 JVM to run Gradle itself. +cd mod/adapters/forge-1.8.9 +JAVA_HOME=/path/to/jdk-8 ./gradlew build # or ./gradlew build -Dorg.gradle.java.home=... ``` Output lands in `build/libs/`. The launcher finds it automatically in development; there @@ -49,10 +53,24 @@ launcher explicitly filters it out for that reason. ### Toolchains -| Adapter | Plugin | Gradle JVM | Compiles to | -|---|---|---|---| -| `forge-1.12.2` | RetroFuturaGradle 1.4.x | Java 17 | Java 8 | -| `forge-modern` | ForgeGradle 6 | Java 21 | Java 21 | +| Adapter | Plugin | Gradle | Gradle JVM | Compiles to | +|---|---|---|---|---| +| `forge-1.8.9` | ForgeGradle 2.1 | 2.14.1 (own wrapper) | Java 8 | Java 8 | +| `forge-1.12.2` | RetroFuturaGradle 1.4.x | 8.2.1 | Java 17 | Java 8 | +| `forge-modern` | ForgeGradle 6 | 8.2.1 | Java 21 | Java 21 | + +**Why 1.8.9 is the odd one out.** RetroFuturaGradle — which is what lets 1.12.2 build on a +modern Gradle — supports exactly two Minecraft versions, 1.7.10 and 1.12.2, because those +are the ones its authors ship modpacks for. No release of it adds 1.8.9, so that adapter +has to use the era-correct ForgeGradle 2.1, which is pinned to Gradle 2.x and Java 8. It +therefore has its own wrapper: `./gradlew` in that directory launches Gradle 2.14.1, not +the 8.2.1 everything else uses. Adapters were always independent builds for exactly this +kind of reason. + +Its `gradle.properties` raises the heap to 3 GB. Gradle 2.14's default is far too small for +the 1.8.9 deobfuscation pass, which dies partway through `deobfMcMCP` with *GC overhead +limit exceeded* — a confusing failure, because nothing in the message suggests memory is +the fixable part. **Why RetroFuturaGradle for 1.12.2.** The original ForgeGradle 2.3 is pinned to Gradle 4.4 and Java 8. RFG provides the same deobfuscation toolchain on modern Gradle. It is pinned @@ -96,12 +114,12 @@ executable. Both are around 80 MB, which is Electron. **The adapters must be built first.** `stage:adapters` fails the build if it finds no jars at all, because an installer without them still launches Minecraft but silently loses live editing — the feature the tool exists for. Adapters that are merely *not written yet* -(`forge-1.8.9`, `forge-mid`) are skipped without complaint, and the launcher reports those +(`forge-mid`) are skipped without complaint, and the launcher reports those versions as vanilla-only at runtime. The jars are packaged as `extraResources`, so they sit next to the asar as ordinary files rather than inside it. The injector copies them into an instance's `mods` folder, and -keeping their real filename (`ella-forge-1.12.2-0.1.0.jar`) matters: the stale-jar cleanup +keeping their real filename (`ella-forge-1.12.2-0.2.0.jar`) matters: the stale-jar cleanup matches `ella-*.jar`, so renaming them would let two Ella mods accumulate in one instance. ### Signing @@ -145,7 +163,7 @@ JDKs `setup-java` installed and reports the Java 8 toolchain as missing. Cutting a release is one command: ```bash -git tag v0.1.0 && git push origin v0.1.0 +git tag v0.2.0 && git push origin v0.2.0 ``` ## Verifying an API before using it diff --git a/docs/project-format.md b/docs/project-format.md index f44432a..3213f68 100644 --- a/docs/project-format.md +++ b/docs/project-format.md @@ -28,14 +28,23 @@ the mod injects into the resource stack. "formatVersion": 1, "name": "My Project", "namespace": "myproject", - "targetVersions": ["1.12.2", "1.21.1"], + "targetVersion": "1.12.2", "slotPool": { "block": 128, "item": 128 }, "entries": [] } ``` -`targetVersions` drives validation: the editor warns when a setting is used that some -listed target cannot honour. +`targetVersion` is the Minecraft version the project is authored against. The launcher +preselects it whenever the project is opened, and launching any other version asks first — +listing what would break in this project's files, and offering to rewrite the ones it can. +See `shared/version-compat.ts` for what "break" means here; both cases are silent in game, +which is why they are worth a dialog. + +`null` means the project is not bound yet: the next launch adopts its version. That is how +a project created before the field existed acquires one. + +> Replaces a `targetVersions` array that nothing ever read past creation. A manifest still +> carrying it is migrated on load — the first entry becomes `targetVersion`. ## Entries diff --git a/docs/protocol.md b/docs/protocol.md index 3becd82..cb715c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -54,7 +54,7 @@ Sent immediately on connect. "loader": "forge", "loaderVersion": "14.23.5.2859", "adapter": "forge-1.12.2", - "adapterVersion": "0.1.0", + "adapterVersion": "0.2.0", "javaVersion": "8", "slots": { "block": 128, "item": 128 }, "capabilities": ["render_layer.cutout", "..."] diff --git a/launcher/locales/en.json b/launcher/locales/en.json index 8840a97..fe6b518 100644 --- a/launcher/locales/en.json +++ b/launcher/locales/en.json @@ -2,10 +2,15 @@ "app.name": "Ella", "app.tagline": "A model-testing workbench for Blockbench authors", + "nav.home": "Home", + "nav.needsProject": "Open a project first", "nav.versions": "Versions", "nav.project": "Project", "nav.editor": "Editor", "nav.logs": "Logs", + "nav.export": "Export", + "nav.settings": "Settings", + "logs.filter": "Filter…", "logs.follow": "Follow", "logs.clear": "Clear", @@ -15,17 +20,71 @@ "logs.level.info": "Info and above", "logs.level.warn": "Warnings and errors", "logs.level.error": "Errors only", - "nav.export": "Export", - "nav.settings": "Settings", + + "home.welcome": "Welcome to Ella", + "home.welcomeText": "Model a block in Blockbench, save, and watch it change in the running game. Five steps and you are live.", + "home.projectSubtitle": "Namespace {namespace}. Everything you make lands under it.", + "home.stat.live": "Live editing", + "home.stat.minecraft": "Minecraft", + "home.stat.blockbench": "Blockbench", + "home.live.on": "Live", + "home.live.off": "Off", + "home.found": "Found", + "home.notFound": "Not found", + "home.noLiveVersion": "None of your installed versions can live-edit, so the game will launch but never pick up your changes.", + "home.noLiveVersionAction": "See which versions can", + "home.quickActions": "What now?", + "home.recent": "Your blocks and items", + "home.action.editor": "Open the editor", + "home.action.editorText": "Tune a block's look and behaviour while the game runs.", + "home.action.newText": "Add another block or item to the project.", + "home.action.exportText": "Package everything as a resource pack zip.", + "home.action.logsText": "Read what the game and the launcher are saying.", + "home.resume": "Pick up where you left off", + "home.resumeHelp": "Opening one of these puts its blocks and items back in the editor and reloads them into a running game.", + "home.newProjectText": "Start a fresh namespace of your own.", + + "guide.title": "Getting set up", + "guide.subtitle": "Ella needs all five of these before an edit in Blockbench can reach the running game. Each one checks itself — nothing here is a box you tick.", + "guide.progress": "{done} of {total}", + "guide.show": "Show the checklist", + "guide.hide": "Hide the checklist", + "guide.version.title": "Install a Minecraft version", + "guide.version.why": "Ella downloads and manages its own copy, so nothing it does can touch your normal installation.", + "guide.version.action": "Choose a version", + "guide.project.title": "Create a project", + "guide.project.why": "A project holds your models, textures and settings, and gives them the namespace the game loads them under.", + "guide.project.action": "Create one", + "guide.entry.title": "Add a block or an item", + "guide.entry.why": "Ella writes a starter model and texture for it, ready to open in Blockbench.", + "guide.entry.action": "Add one", + "guide.blockbench.title": "Point Ella at Blockbench", + "guide.blockbench.why": "Ella hands Blockbench the very model file the game reads, so saving is the update — there is no import or export in between.", + "guide.blockbench.action": "Set the path", + "guide.launch.title": "Launch the game", + "guide.launch.why": "Ella's mod connects back to the launcher on startup. Once it does, every save appears in game without a reload.", + "guide.launch.action": "Launch", + + "progress.download": "Downloading", + "progress.install": "Installing", + "progress.export": "Exporting", + + "status.adapter": "Running version, loader and Ella adapter", "versions.title": "Minecraft version", + "versions.subtitle": "Ella keeps its own installations, separate from the official launcher.", + "versions.recommended": "Ready for live editing", + "versions.recommendedHelp": "These versions have an Ella adapter built, so edits reach the running game. Any other version installs and launches, but only as plain Minecraft.", + "versions.refresh": "Refresh", + "versions.installing": "Installing…", + "versions.installDone": "Minecraft {version} installed", + "versions.uninstallDone": "Minecraft {version} uninstalled", + "versions.noneInstalledHelp": "Pick one from the list below. The first install downloads a few hundred megabytes; later ones reuse most of it.", + "versions.alreadyRunning": "Stop the running game first", + "versions.javaFoundHelp": "A matching Java runtime was found on this machine.", + "versions.vanillaOnlyHelp": "No Ella adapter covers this version, so it launches as plain Minecraft.", "versions.installed": "Installed", "versions.noneInstalled": "No version installed yet. Install one below to get started.", - "quick.version": "Version", - "quick.launch": "Launch", - "quick.stop": "Stop", - "quick.noVersions": "Install a version to launch from here.", - "quick.vanillaOnly": "No adapter for this version — launches without live editing.", "versions.available": "Available", "versions.showSnapshots": "Show snapshots", "versions.install": "Install", @@ -48,14 +107,38 @@ "versions.javaRequired": "Requires Java {java}", "versions.javaMissing": "Java {java} was not found on this machine", "versions.javaFound": "Using Java {version}", + "versions.repair": "Repair", + "versions.repairHelp": "Download anything missing again — keeps your worlds and settings.", + "versions.repairDone": "Minecraft {version} repaired", + + "quick.title": "Quick launch", + "quick.version": "Version", + "quick.launch": "Launch", + "quick.stop": "Stop", + "quick.noVersions": "Install a version to launch from here.", + "quick.vanillaOnly": "No adapter for this version — launches without live editing.", + "quick.projectTargets": "This project is made for {version}.", + "quick.projectTargetsMissing": "This project is made for {version}, which is not installed.", "project.title": "Project", "project.new": "New project", + "project.newSubtitle": "A project is one namespace and everything under it — your models, your textures and the settings for each one.", + "project.create": "Create the project", + "project.namePlaceholder": "e.g. Medieval set", + "project.nameRequired": "Give the project a name first", + "project.createdDone": "Created “{name}”", + "project.savedDone": "Project saved", + "project.deletedDone": "Deleted “{name}”", + "project.noEntriesHelp": "Add a block or an item and Ella writes a starter model and texture you can open straight in Blockbench.", + "project.noProjectHelp": "Projects keep your models, textures and settings together under one namespace.", "project.open": "Open project", "project.name": "Name", "project.namespace": "Namespace", "project.namespaceHelp": "Lowercase letters, digits and underscores. Used in resource paths.", - "project.targetVersions": "Target versions", + "project.targetVersion": "Minecraft version", + "project.targetVersionHelp": "The version this project is authored for. It is preselected when the project is opened, and launching another one asks first.", + "project.targetVersionNone": "Not bound yet — the next launch decides", + "project.targetVersionMissing": "{version} (not installed)", "project.entries": "Blocks and items", "project.none": "No project open", "project.close": "Close", @@ -77,10 +160,23 @@ "entry.idHelp": "Lowercase letters, digits and underscores.", "entry.registryName": "Registry name on export", "entry.translationKey": "Translation key on export", + "entry.previewRotate": "Drag to turn the model · double-click to reset", "entry.displayName": "Display name", "entry.kind": "Type", "entry.kind.block": "Block", "entry.kind.item": "Item", + "entry.kind.block.help": "Placed in the world. Has a hitbox, a hardness and a render layer.", + "entry.kind.item.help": "Held in the hand or the inventory. Simpler, and always available.", + "entry.displayNameOptional": "Optional", + "entry.nameRequired": "Give it a name first", + "entry.needsGame": "Launch the game first", + "entry.needsSlot": "Not bound to a slot — restart the game to bind it", + "entry.createdDone": "Created “{name}”", + "entry.deletedDone": "Removed “{id}”", + "entry.renamedDone": "Renamed “{from}” to “{to}”", + "entry.renamedTitleDone": "Now called “{name}”", + "entry.giveDone": "Added to your inventory", + "entry.placeDone": "Placed in front of you", "entry.delete": "Delete", "entry.deleteTitle": "Remove “{id}” from the project?", "entry.deleteExplain": "The entry is removed and its slot freed. Your model and texture files are kept unless you tick the box below.", @@ -92,6 +188,8 @@ "entry.place": "Place in front of player", "entry.slot": "Slot", "entry.unbound": "Not bound to a slot", + "entry.settings": "Settings", + "entry.create": "Create", "texture.title": "Textures", "texture.add": "Add texture", @@ -109,17 +207,37 @@ "texture.usedBy": "Used by: {faces}", "texture.unused": "Not used by any face.", "texture.orphanedFaces": "Removed “{key}”, but these faces still reference it and will fail to load: {faces}", + "texture.removedDone": "Removed the “{key}” texture", "model.source": "Model source", "model.source.json": "Vanilla JSON", "model.source.bbmodel": "Blockbench file", "model.source.obj": "OBJ mesh", "model.source.objUnavailable": "OBJ support is not implemented yet", + "model.parentTrap": "This model inherits from {parent}, which on Minecraft 1.8.x replaces its own {count} shape(s) with the parent's — so it renders as a plain cube in game.", + "model.parentRemoved": "Removed the {parent} parent — your own shapes now render.", + "compat.title": "Launch on a different version?", + "compat.subtitle": "“{project}” is made for {from}, and you are about to launch {to}.", + "compat.files": "In this project's files", + "compat.noIssues": "Nothing in this project needs changing for {to}.", + "compat.fixable": "Ella can fix it", + "compat.manual": "Needs a manual fix", + "compat.automatic": "The pack format and the slot files are regenerated for whichever version connects, so those need nothing from you.", + "compat.adopt": "Move this project to {to}", + "compat.adoptAndFix": "Move this project to {to} and rewrite {count} model file(s)", + "compat.oneOff": "Left unticked, this run is a one-off: the project stays on {from} and its files are not touched.", + "compat.launch": "Launch {to}", + "compat.issue.parentOverridesGeometry": "Its {parent} parent wins over the model's own geometry before 1.9 — it would render as the parent instead.", + "compat.issue.vanillaTextureFolder": "Refers to the vanilla texture {reference}, which on {to} is called {expected} — it would show the missing-texture checkerboard.", + "compat.note.notInstalled": "{version} is not installed.", + "compat.note.noAdapter": "No Ella adapter covers {version}, so live editing will not work there.", + "compat.note.plannedAdapter": "The adapter for {version} is planned but not built yet, so live editing will not work there.", + "compat.note.losesLiveEditing": "{from} supports live editing and {to} does not — edits will stop reaching the game.", + "compat.note.javaMissing": "{version} needs Java {java}, and no matching runtime was found.", "settings.group.appearance": "Appearance", "settings.group.physical": "Physical", "settings.group.interaction": "Interaction", - "settings.block.renderLayer": "Render layer", "settings.block.renderLayer.help": "How the block's faces are drawn. Anything other than Solid also needs Occludes neighbours turned off.", "settings.block.opaque": "Occludes neighbours", @@ -140,12 +258,10 @@ "settings.block.collision": "Collision", "settings.block.hitbox": "Hitbox", "settings.block.hitbox.help": "Bounds in model space, 0 to 16 on each axis.", - "settings.renderLayer.solid": "Solid", "settings.renderLayer.cutout": "Cutout", "settings.renderLayer.cutoutMipped": "Cutout (mipped)", "settings.renderLayer.translucent": "Translucent", - "settings.soundType.stone": "Stone", "settings.soundType.wood": "Wood", "settings.soundType.gravel": "Gravel", @@ -155,27 +271,39 @@ "settings.soundType.wool": "Wool", "settings.soundType.sand": "Sand", "settings.soundType.snow": "Snow", - "settings.block.rotation": "Rotation", "settings.block.rotation.help": "How the block orients itself when placed. Your model should face north; the game turns it.", "settings.rotation.none": "Fixed", "settings.rotation.horizontal": "Faces the player (4 ways)", "settings.rotation.all": "Faces the clicked side (6 ways)", - "settings.collision.full": "Full block", "settings.collision.none": "Walk through", "settings.collision.custom": "Custom box", - "settings.item.handheld": "Held like a tool", "settings.item.handheld.help": "Uses the handheld model parent, angled in the hand.", "settings.item.glint": "Enchantment glint", "settings.item.stackSize": "Stack size", "settings.item.rarity": "Rarity", - "settings.rarity.common": "Common", "settings.rarity.uncommon": "Uncommon", "settings.rarity.rare": "Rare", "settings.rarity.epic": "Epic", + "settings.title": "Settings", + "settings.subtitle": "Where Ella finds your tools, and how much room it reserves in game.", + "settings.language": "Language", + "settings.username": "In-game name", + "settings.usernameHelp": "The name Ella launches Minecraft under. Offline only — no account is involved.", + "settings.blockbench": "Blockbench", + "settings.blockbenchPath": "Blockbench path", + "settings.blockbenchPlaceholder": "Leave empty to detect it automatically", + "settings.blockbenchDetected": "Detected automatically at {path}", + "settings.blockbenchOk": "Ella will use this executable.", + "settings.blockbenchMissing": "Ella could not find Blockbench. Set the path here, or install it from blockbench.net.", + "settings.javaHelp": "Ella picks the right runtime per Minecraft version. Old versions need Java 8; recent ones need 17 or 21.", + "settings.javaNone": "No Java runtime was found, so no version can be launched. Install a JDK and reopen Ella.", + "settings.dataDirectory": "Data directory", + "settings.slotPool": "Slot pool size", + "settings.slotPoolHelp": "How many blocks and items are reserved at startup. Higher costs nothing but needs a restart to change.", "warning.transparentButOpaque": "This render layer draws transparency, but the block still occludes its neighbours, so it will look solid in game. Turn off Occludes neighbours.", "warning.nonOpaqueSolidLayer": "The block does not occlude neighbours but uses the Solid layer, so transparent pixels will render black. Switch to Cutout.", @@ -191,14 +319,23 @@ "game.reloading": "Reloading resources…", "game.reloaded": "Resources reloaded", "game.slotsUsed": "{used} of {total} slots used", + "game.slotsHelp": "Ella reserves a fixed pool of block and item slots when the game starts; each of your entries takes one.", "game.slotsFull": "All slots are in use. Restart the game to free them.", "blockbench.notFound": "Blockbench was not found", "blockbench.notFoundHelp": "Set the path to Blockbench in Settings.", + "blockbench.setPath": "Set its path", "blockbench.opening": "Opening Blockbench…", "blockbench.watching": "Watching for changes", "export.title": "Export", + "export.subtitle": "Turn what you have made into something you can hand to someone else.", + "export.running": "Exporting…", + "export.doneShort": "Resource pack exported", + "export.files": "files", + "export.blocked": "Fix the errors below, then export again.", + "export.needsEntries": "Add a block or an item first", + "export.planned": "Planned", "export.resourcePack": "Resource pack", "export.resourcePackHelp": "A zip you can drop straight into the resource pack folder.", "export.mod": "Forge mod", @@ -216,13 +353,7 @@ "plugin.reinstall": "Reinstall", "plugin.installed": "Installed", "plugin.outdated": "Update available", - - "settings.title": "Settings", - "settings.language": "Language", - "settings.blockbenchPath": "Blockbench path", - "settings.dataDirectory": "Data directory", - "settings.slotPool": "Slot pool size", - "settings.slotPoolHelp": "How many blocks and items are reserved at startup. Higher costs nothing but needs a restart to change.", + "plugin.installedDone": "Plugin installed — restart Blockbench to load it", "crash.title": "Minecraft {version} stopped unexpectedly", "crash.report": "Crash report", @@ -241,5 +372,10 @@ "common.close": "Close", "common.browse": "Browse…", "common.retry": "Retry", - "common.error": "Error" + "common.error": "Error", + "common.noChanges": "Nothing has changed", + "common.undo": "Undo", + "common.undone": "Change undone", + "common.dismiss": "Dismiss", + "common.saving": "Saving…" } diff --git a/launcher/locales/fr.json b/launcher/locales/fr.json index dbd382b..621ce33 100644 --- a/launcher/locales/fr.json +++ b/launcher/locales/fr.json @@ -2,10 +2,15 @@ "app.name": "Ella", "app.tagline": "Un atelier de test de modèles pour les auteurs Blockbench", + "nav.home": "Accueil", + "nav.needsProject": "Ouvre d'abord un projet", "nav.versions": "Versions", "nav.project": "Projet", "nav.editor": "Éditeur", "nav.logs": "Logs", + "nav.export": "Export", + "nav.settings": "Réglages", + "logs.filter": "Filtrer…", "logs.follow": "Suivre", "logs.clear": "Effacer", @@ -15,17 +20,71 @@ "logs.level.info": "Info et plus", "logs.level.warn": "Avertissements et erreurs", "logs.level.error": "Erreurs seulement", - "nav.export": "Export", - "nav.settings": "Réglages", + + "home.welcome": "Bienvenue dans Ella", + "home.welcomeText": "Modélise un bloc dans Blockbench, enregistre, et regarde-le changer dans le jeu en cours. Cinq étapes et tu es en live.", + "home.projectSubtitle": "Espace de noms {namespace}. Tout ce que tu crées atterrit dessous.", + "home.stat.live": "Édition live", + "home.stat.minecraft": "Minecraft", + "home.stat.blockbench": "Blockbench", + "home.live.on": "Live", + "home.live.off": "Inactive", + "home.found": "Trouvé", + "home.notFound": "Introuvable", + "home.noLiveVersion": "Aucune de tes versions installées ne gère l'édition live : le jeu se lancera mais ne reprendra jamais tes modifications.", + "home.noLiveVersionAction": "Voir celles qui le peuvent", + "home.quickActions": "Et maintenant ?", + "home.recent": "Tes blocs et objets", + "home.action.editor": "Ouvrir l'éditeur", + "home.action.editorText": "Ajuste l'aspect et le comportement d'un bloc pendant que le jeu tourne.", + "home.action.newText": "Ajoute un autre bloc ou objet au projet.", + "home.action.exportText": "Empaquette tout dans un zip de pack de ressources.", + "home.action.logsText": "Lis ce que disent le jeu et le launcher.", + "home.resume": "Reprendre où tu en étais", + "home.resumeHelp": "Ouvrir l’un d’eux remet ses blocs et objets dans l’éditeur et les recharge dans un jeu en cours.", + "home.newProjectText": "Démarrer un nouvel espace de noms à toi.", + + "guide.title": "Mise en route", + "guide.subtitle": "Ella a besoin de ces cinq points avant qu'une modification dans Blockbench puisse atteindre le jeu en cours. Chacun se vérifie tout seul — rien ici n'est une case à cocher.", + "guide.progress": "{done} sur {total}", + "guide.show": "Afficher la checklist", + "guide.hide": "Masquer la checklist", + "guide.version.title": "Installer une version de Minecraft", + "guide.version.why": "Ella télécharge et gère sa propre copie : rien de ce qu'elle fait ne touche à ton installation habituelle.", + "guide.version.action": "Choisir une version", + "guide.project.title": "Créer un projet", + "guide.project.why": "Un projet regroupe tes modèles, tes textures et leurs réglages, et leur donne l'espace de noms sous lequel le jeu les charge.", + "guide.project.action": "En créer un", + "guide.entry.title": "Ajouter un bloc ou un objet", + "guide.entry.why": "Ella lui écrit un modèle et une texture de départ, prêts à ouvrir dans Blockbench.", + "guide.entry.action": "En ajouter un", + "guide.blockbench.title": "Indiquer Blockbench à Ella", + "guide.blockbench.why": "Ella confie à Blockbench le fichier de modèle que le jeu lit vraiment : enregistrer, c'est mettre à jour — il n'y a ni import ni export entre les deux.", + "guide.blockbench.action": "Indiquer le chemin", + "guide.launch.title": "Lancer le jeu", + "guide.launch.why": "Le mod d'Ella se reconnecte au launcher au démarrage. Une fois connecté, chaque sauvegarde apparaît en jeu sans rechargement.", + "guide.launch.action": "Lancer", + + "progress.download": "Téléchargement", + "progress.install": "Installation", + "progress.export": "Export", + + "status.adapter": "Version, loader et adaptateur Ella en cours", "versions.title": "Version de Minecraft", + "versions.subtitle": "Ella gère ses propres installations, séparées de celles du launcher officiel.", + "versions.recommended": "Prêtes pour l'édition live", + "versions.recommendedHelp": "Ces versions disposent d'un adaptateur Ella construit : les modifications atteignent le jeu en cours. Toute autre version s'installe et se lance, mais en Minecraft ordinaire.", + "versions.refresh": "Actualiser", + "versions.installing": "Installation…", + "versions.installDone": "Minecraft {version} installée", + "versions.uninstallDone": "Minecraft {version} désinstallée", + "versions.noneInstalledHelp": "Choisis-en une dans la liste ci-dessous. La première installation télécharge quelques centaines de mégaoctets ; les suivantes en réutilisent l'essentiel.", + "versions.alreadyRunning": "Arrête d'abord le jeu en cours", + "versions.javaFoundHelp": "Un runtime Java correspondant a été trouvé sur cette machine.", + "versions.vanillaOnlyHelp": "Aucun adaptateur Ella ne couvre cette version : elle se lance en Minecraft ordinaire.", "versions.installed": "Installées", "versions.noneInstalled": "Aucune version installée. Installe-en une ci-dessous pour commencer.", - "quick.version": "Version", - "quick.launch": "Lancer", - "quick.stop": "Arrêter", - "quick.noVersions": "Installe une version pour la lancer d'ici.", - "quick.vanillaOnly": "Pas d'adaptateur pour cette version — lancement sans édition live.", "versions.available": "Disponibles", "versions.showSnapshots": "Afficher les snapshots", "versions.install": "Installer", @@ -48,14 +107,38 @@ "versions.javaRequired": "Nécessite Java {java}", "versions.javaMissing": "Java {java} est introuvable sur cette machine", "versions.javaFound": "Utilise Java {version}", + "versions.repair": "Réparer", + "versions.repairHelp": "Retélécharge ce qui manque — conserve tes mondes et tes réglages.", + "versions.repairDone": "Minecraft {version} réparée", + + "quick.title": "Lancement rapide", + "quick.version": "Version", + "quick.launch": "Lancer", + "quick.stop": "Arrêter", + "quick.noVersions": "Installe une version pour la lancer d'ici.", + "quick.vanillaOnly": "Pas d'adaptateur pour cette version — lancement sans édition live.", + "quick.projectTargets": "Ce projet est fait pour la {version}.", + "quick.projectTargetsMissing": "Ce projet est fait pour la {version}, qui n'est pas installée.", "project.title": "Projet", "project.new": "Nouveau projet", + "project.newSubtitle": "Un projet, c'est un espace de noms et tout ce qu'il contient — tes modèles, tes textures et les réglages de chacun.", + "project.create": "Créer le projet", + "project.namePlaceholder": "ex. Set médiéval", + "project.nameRequired": "Donne d'abord un nom au projet", + "project.createdDone": "« {name} » créé", + "project.savedDone": "Projet enregistré", + "project.deletedDone": "« {name} » supprimé", + "project.noEntriesHelp": "Ajoute un bloc ou un objet et Ella lui écrit un modèle et une texture de départ, ouvrables directement dans Blockbench.", + "project.noProjectHelp": "Un projet regroupe tes modèles, tes textures et leurs réglages sous un même espace de noms.", "project.open": "Ouvrir un projet", "project.name": "Nom", "project.namespace": "Espace de noms", "project.namespaceHelp": "Minuscules, chiffres et tirets bas. Utilisé dans les chemins de ressources.", - "project.targetVersions": "Versions ciblées", + "project.targetVersion": "Version de Minecraft", + "project.targetVersionHelp": "La version pour laquelle ce projet est conçu. Elle est présélectionnée à l'ouverture du projet, et lancer une autre version demande confirmation.", + "project.targetVersionNone": "Pas encore liée — le prochain lancement décidera", + "project.targetVersionMissing": "{version} (non installée)", "project.entries": "Blocs et objets", "project.none": "Aucun projet ouvert", "project.close": "Fermer", @@ -77,10 +160,23 @@ "entry.idHelp": "Minuscules, chiffres et tirets bas.", "entry.registryName": "Nom de registre à l'export", "entry.translationKey": "Clé de traduction à l'export", + "entry.previewRotate": "Glissez pour tourner le modèle · double-clic pour réinitialiser", "entry.displayName": "Nom affiché", "entry.kind": "Type", "entry.kind.block": "Bloc", "entry.kind.item": "Objet", + "entry.kind.block.help": "Se pose dans le monde. A une boîte de collision, une dureté et une couche de rendu.", + "entry.kind.item.help": "Se tient en main ou dans l'inventaire. Plus simple, et toujours disponible.", + "entry.displayNameOptional": "Optionnel", + "entry.nameRequired": "Donne-lui d'abord un nom", + "entry.needsGame": "Lance d'abord le jeu", + "entry.needsSlot": "Associé à aucun emplacement — relance le jeu pour l'associer", + "entry.createdDone": "« {name} » créé", + "entry.deletedDone": "« {id} » retiré", + "entry.renamedDone": "« {from} » renommé en « {to} »", + "entry.renamedTitleDone": "S'appelle maintenant « {name} »", + "entry.giveDone": "Ajouté à ton inventaire", + "entry.placeDone": "Posé devant toi", "entry.delete": "Supprimer", "entry.deleteTitle": "Retirer « {id} » du projet ?", "entry.deleteExplain": "L'entrée est retirée et son emplacement libéré. Tes fichiers de modèle et de texture sont conservés, sauf si tu coches la case ci-dessous.", @@ -92,6 +188,8 @@ "entry.place": "Poser devant le joueur", "entry.slot": "Emplacement", "entry.unbound": "Associé à aucun emplacement", + "entry.settings": "Réglages", + "entry.create": "Créer", "texture.title": "Textures", "texture.add": "Ajouter une texture", @@ -109,17 +207,37 @@ "texture.usedBy": "Utilisée par : {faces}", "texture.unused": "Utilisée par aucune face.", "texture.orphanedFaces": "« {key} » retirée, mais ces faces y font encore référence et ne se chargeront pas : {faces}", + "texture.removedDone": "Texture « {key} » retirée", "model.source": "Source du modèle", "model.source.json": "JSON vanilla", "model.source.bbmodel": "Fichier Blockbench", "model.source.obj": "Maillage OBJ", "model.source.objUnavailable": "La prise en charge de l'OBJ n'est pas encore implémentée", + "model.parentTrap": "Ce modèle hérite de {parent}, ce qui sur Minecraft 1.8.x remplace ses {count} forme(s) par celles du parent — il s'affiche donc comme un cube ordinaire en jeu.", + "model.parentRemoved": "Parent {parent} retiré — tes propres formes s'affichent maintenant.", + "compat.title": "Lancer sur une autre version ?", + "compat.subtitle": "« {project} » est fait pour la {from}, et tu t'apprêtes à lancer la {to}.", + "compat.files": "Dans les fichiers du projet", + "compat.noIssues": "Rien dans ce projet n'a besoin d'être modifié pour la {to}.", + "compat.fixable": "Ella peut corriger", + "compat.manual": "À corriger à la main", + "compat.automatic": "Le format du pack et les fichiers de slots sont régénérés pour la version qui se connecte : rien à faire de ce côté.", + "compat.adopt": "Passer ce projet en {to}", + "compat.adoptAndFix": "Passer ce projet en {to} et réécrire {count} fichier(s) de modèle", + "compat.oneOff": "Sans cette case, ce lancement est ponctuel : le projet reste en {from} et ses fichiers ne sont pas touchés.", + "compat.launch": "Lancer la {to}", + "compat.issue.parentOverridesGeometry": "Son parent {parent} l'emporte sur la géométrie du modèle avant la 1.9 — il s'afficherait comme le parent.", + "compat.issue.vanillaTextureFolder": "Référence la texture vanilla {reference}, qui s'appelle {expected} en {to} — elle afficherait le damier de texture manquante.", + "compat.note.notInstalled": "La {version} n'est pas installée.", + "compat.note.noAdapter": "Aucun adaptateur Ella ne couvre la {version} : pas d'édition en direct.", + "compat.note.plannedAdapter": "L'adaptateur pour la {version} est prévu mais pas encore compilé : pas d'édition en direct.", + "compat.note.losesLiveEditing": "La {from} permet l'édition en direct, pas la {to} — les modifications n'arriveront plus au jeu.", + "compat.note.javaMissing": "La {version} demande Java {java}, introuvable sur cette machine.", "settings.group.appearance": "Apparence", "settings.group.physical": "Physique", "settings.group.interaction": "Interaction", - "settings.block.renderLayer": "Couche de rendu", "settings.block.renderLayer.help": "Façon dont les faces du bloc sont dessinées. Toute valeur autre que Opaque nécessite aussi de désactiver Masque les voisins.", "settings.block.opaque": "Masque les voisins", @@ -140,12 +258,10 @@ "settings.block.collision": "Collision", "settings.block.hitbox": "Boîte de collision", "settings.block.hitbox.help": "Limites dans l'espace du modèle, de 0 à 16 sur chaque axe.", - "settings.renderLayer.solid": "Opaque", "settings.renderLayer.cutout": "Découpe", "settings.renderLayer.cutoutMipped": "Découpe (mipmap)", "settings.renderLayer.translucent": "Translucide", - "settings.soundType.stone": "Pierre", "settings.soundType.wood": "Bois", "settings.soundType.gravel": "Gravier", @@ -155,27 +271,39 @@ "settings.soundType.wool": "Laine", "settings.soundType.sand": "Sable", "settings.soundType.snow": "Neige", - "settings.block.rotation": "Rotation", "settings.block.rotation.help": "Orientation du bloc à la pose. Ton modèle doit être orienté vers le nord ; le jeu le tourne.", "settings.rotation.none": "Fixe", "settings.rotation.horizontal": "Face au joueur (4 sens)", "settings.rotation.all": "Face au côté cliqué (6 sens)", - "settings.collision.full": "Bloc plein", "settings.collision.none": "Traversable", "settings.collision.custom": "Boîte personnalisée", - "settings.item.handheld": "Tenu comme un outil", "settings.item.handheld.help": "Utilise le modèle parent handheld, incliné dans la main.", "settings.item.glint": "Reflet d'enchantement", "settings.item.stackSize": "Taille de pile", "settings.item.rarity": "Rareté", - "settings.rarity.common": "Commun", "settings.rarity.uncommon": "Peu commun", "settings.rarity.rare": "Rare", "settings.rarity.epic": "Épique", + "settings.title": "Réglages", + "settings.subtitle": "Où Ella trouve tes outils, et combien de place elle réserve en jeu.", + "settings.language": "Langue", + "settings.username": "Pseudo en jeu", + "settings.usernameHelp": "Le nom sous lequel Ella lance Minecraft. Hors ligne uniquement — aucun compte n'est impliqué.", + "settings.blockbench": "Blockbench", + "settings.blockbenchPath": "Chemin de Blockbench", + "settings.blockbenchPlaceholder": "Laisse vide pour une détection automatique", + "settings.blockbenchDetected": "Détecté automatiquement dans {path}", + "settings.blockbenchOk": "Ella utilisera cet exécutable.", + "settings.blockbenchMissing": "Ella n'a pas trouvé Blockbench. Indique le chemin ici, ou installe-le depuis blockbench.net.", + "settings.javaHelp": "Ella choisit le bon runtime selon la version de Minecraft. Les anciennes versions demandent Java 8 ; les récentes 17 ou 21.", + "settings.javaNone": "Aucun runtime Java trouvé : aucune version ne peut être lancée. Installe un JDK et rouvre Ella.", + "settings.dataDirectory": "Dossier de données", + "settings.slotPool": "Taille du pool d'emplacements", + "settings.slotPoolHelp": "Nombre de blocs et d'objets réservés au démarrage. Une valeur élevée ne coûte rien, mais la modifier nécessite un redémarrage.", "warning.transparentButOpaque": "Cette couche de rendu gère la transparence, mais le bloc masque toujours ses voisins : il paraîtra opaque en jeu. Désactive Masque les voisins.", "warning.nonOpaqueSolidLayer": "Le bloc ne masque pas ses voisins mais utilise la couche Opaque : les pixels transparents s'afficheront en noir. Passe en Découpe.", @@ -191,14 +319,23 @@ "game.reloading": "Rechargement des ressources…", "game.reloaded": "Ressources rechargées", "game.slotsUsed": "{used} emplacements sur {total} utilisés", + "game.slotsHelp": "Ella réserve au démarrage du jeu un pool fixe d'emplacements de blocs et d'objets ; chacune de tes entrées en occupe un.", "game.slotsFull": "Tous les emplacements sont occupés. Relance le jeu pour en libérer.", "blockbench.notFound": "Blockbench est introuvable", "blockbench.notFoundHelp": "Indique le chemin vers Blockbench dans les Réglages.", + "blockbench.setPath": "Indiquer son chemin", "blockbench.opening": "Ouverture de Blockbench…", "blockbench.watching": "Surveillance des modifications", "export.title": "Export", + "export.subtitle": "Transforme ce que tu as créé en quelque chose que tu peux transmettre.", + "export.running": "Export en cours…", + "export.doneShort": "Pack de ressources exporté", + "export.files": "fichiers", + "export.blocked": "Corrige les erreurs ci-dessous, puis relance l'export.", + "export.needsEntries": "Ajoute d'abord un bloc ou un objet", + "export.planned": "Prévu", "export.resourcePack": "Pack de ressources", "export.resourcePackHelp": "Un zip à déposer directement dans le dossier des packs de ressources.", "export.mod": "Mod Forge", @@ -216,13 +353,7 @@ "plugin.reinstall": "Réinstaller", "plugin.installed": "Installé", "plugin.outdated": "Mise à jour disponible", - - "settings.title": "Réglages", - "settings.language": "Langue", - "settings.blockbenchPath": "Chemin de Blockbench", - "settings.dataDirectory": "Dossier de données", - "settings.slotPool": "Taille du pool d'emplacements", - "settings.slotPoolHelp": "Nombre de blocs et d'objets réservés au démarrage. Une valeur élevée ne coûte rien, mais la modifier nécessite un redémarrage.", + "plugin.installedDone": "Plugin installé — relance Blockbench pour le charger", "crash.title": "Minecraft {version} s'est arrêté de façon inattendue", "crash.report": "Rapport de crash", @@ -241,5 +372,10 @@ "common.close": "Fermer", "common.browse": "Parcourir…", "common.retry": "Réessayer", - "common.error": "Erreur" + "common.error": "Erreur", + "common.noChanges": "Rien n'a changé", + "common.undo": "Annuler", + "common.undone": "Modification annulée", + "common.dismiss": "Fermer", + "common.saving": "Enregistrement…" } diff --git a/launcher/package-lock.json b/launcher/package-lock.json index 3032fd0..45de550 100644 --- a/launcher/package-lock.json +++ b/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16" diff --git a/launcher/package.json b/launcher/package.json index 39330a7..f618853 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "description": "Ella — a model-testing workbench for Blockbench authors", "main": "./out/main/index.js", "author": "Ella", diff --git a/launcher/src/main/blockbench.ts b/launcher/src/main/blockbench.ts index 5826fc8..e241b33 100644 --- a/launcher/src/main/blockbench.ts +++ b/launcher/src/main/blockbench.ts @@ -63,7 +63,18 @@ export class ModelWatcher extends EventEmitter { * because editors commonly save by writing a temporary file and renaming it over the * target, which breaks a watch bound to the original inode. */ - async watchDirectory(directory: string): Promise { + async watchDirectory( + directory: string, + options: { + /** + * Paths to ignore, relative to `directory` and always with `/` separators. + * + * Needed because the watched tree contains generated output as well as the author's + * files. Without it Ella hears its own writes and reacts to them. + */ + ignore?: (relativePath: string) => boolean; + } = {}, + ): Promise { if (this.watchers.has(directory)) return; const exists = await stat(directory).then((s) => s.isDirectory(), () => false); @@ -76,6 +87,7 @@ export class ModelWatcher extends EventEmitter { if (!/\.(json|png)$/i.test(name)) return; // Ignore the temporary files atomic saves leave behind. if (name.endsWith('.tmp') || name.endsWith('.part')) return; + if (options.ignore?.(name.split(path.sep).join('/'))) return; this.pending.add(path.join(directory, name)); this.schedule(); diff --git a/launcher/src/main/diagnostics.ts b/launcher/src/main/diagnostics.ts index 95beb46..974a9f2 100644 --- a/launcher/src/main/diagnostics.ts +++ b/launcher/src/main/diagnostics.ts @@ -16,7 +16,7 @@ import { instanceDir, instanceModsDir } from './paths.ts'; import { findForgeVersionId } from './minecraft/forge.ts'; import { selectJavaFor } from './java-runtime.ts'; import { adapterCoverageFor, requiredJavaVersion } from '../shared/version.ts'; -import { LAUNCHER_VERSION } from './minecraft/launch.ts'; +import { APP_VERSION } from '../shared/app.ts'; /** Crash reports run long; keep enough to diagnose without producing an unusable wall. */ const MAX_CRASH_REPORT_LINES = 120; @@ -180,7 +180,7 @@ export async function collectCrashDiagnostics( context.javaUsed ?? (await selectJavaFor(versionId).catch(() => null)); const environment: Record = { - 'Ella': LAUNCHER_VERSION, + 'Ella': APP_VERSION, 'Minecraft': versionId, 'Forge': forgeVersionId ?? 'not installed', 'Launched': context.launchedVersionId ?? versionId, diff --git a/launcher/src/main/index.ts b/launcher/src/main/index.ts index d7b1051..1d0ec1f 100644 --- a/launcher/src/main/index.ts +++ b/launcher/src/main/index.ts @@ -8,7 +8,9 @@ import { fileURLToPath } from 'node:url'; import { CHANNELS, EVENTS, type Result } from '../shared/ipc.ts'; import { setDataRoot, ensureLayout, instanceDir } from './paths.ts'; import { formatDiagnostics, type CrashDiagnostics } from './diagnostics.ts'; -import { loadConfig, saveConfig } from './config.ts'; +import { loadConfig, saveConfig, resolveBlockbenchPath } from './config.ts'; +import { createSplash, MIN_SPLASH_MS } from './splash.ts'; +import { translate } from '../shared/i18n.ts'; import { discoverJavaRuntimes } from './java-runtime.ts'; import { listVersions, @@ -33,11 +35,17 @@ import { measureProject, updateProjectInfo, readEntryPreviews, + removeModelParent, + restoreEntry, + writeModelFile, } from './project.ts'; +import { UndoRegistry } from './undo.ts'; +import { planVersionChange, applyVersionChange } from './version-change.ts'; import { listTextures, addTexture, removeTexture, + restoreTexture, setParticleTexture, importTextureFor, } from './textures.ts'; @@ -48,6 +56,7 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); let window: BrowserWindow | null = null; const session = new Session(); +const undoable = new UndoRegistry(); /** Wraps a handler so IPC never rejects: the renderer always receives a Result. */ function handle(channel: string, handler: (...args: never[]) => Promise | T): void { @@ -83,6 +92,21 @@ function requireProject(): { project: NonNullable; root: str return { project: session.project, root: session.projectRoot }; } +/** + * Announces a change that can still be taken back. + * + * The inverse re-reads the open project rather than capturing it: seconds pass between the + * offer and the click, and applying an undo to the project as it was would quietly discard + * anything done in between. + */ +function offerUndo( + messageKey: string, + values: Record, + inverse: () => Promise, +): void { + send(EVENTS.undo, undoable.offer(messageKey, values, inverse)); +} + function findEntry(id: string) { const { project } = requireProject(); const entry = project.entries.find((candidate) => candidate.id === id); @@ -194,6 +218,10 @@ function registerHandlers(): void { }); handle(CHANNELS.projectsOpen, async (root: never) => { + // Every pending inverse names files and entries in the project being left. Applied to + // the next one they would restore something into a project it never belonged to. + undoable.clear(); + const project = await session.openProject(root); await saveConfig({ lastProject: root }); return project; @@ -212,7 +240,10 @@ function registerHandlers(): void { handle(CHANNELS.projectsDelete, async (root: never) => { // Close first: deleting the directory a watcher is bound to leaves the session // pointing at files that no longer exist. - if (session.projectRoot === root) session.closeProject(); + if (session.projectRoot === root) { + undoable.clear(); + session.closeProject(); + } await deleteProject(root); const config = await loadConfig(); @@ -222,6 +253,7 @@ function registerHandlers(): void { }); handle(CHANNELS.projectsClose, () => { + undoable.clear(); session.closeProject(); }); @@ -234,6 +266,33 @@ function registerHandlers(): void { return updated; }); + handle(CHANNELS.projectsPlanVersionChange, (versionId: never) => { + const { project, root } = requireProject(); + return planVersionChange(root, project, versionId); + }); + + handle(CHANNELS.projectsApplyVersionChange, async (versionId: never, migrate: never) => { + const { project, root } = requireProject(); + const result = await applyVersionChange(root, project, versionId, { migrate }); + + // Rewritten models are the files the game reads, so the session has to adopt the new + // project and push: skipping this would leave the running game on the old geometry + // until something else happened to trigger a reload. + await session.setProject(result.project); + + if (result.migrated.length > 0) { + send(EVENTS.log, { + level: 'info', + source: 'ella', + message: + `Adapted ${result.migrated.length} model(s) to Minecraft ${versionId}: ` + + result.migrated.join(', '), + }); + } + + return result; + }); + handle(CHANNELS.entriesCreate, async (options: never) => { const { project, root } = requireProject(); const { project: updated, entry } = await createEntry(root, project, options); @@ -243,8 +302,22 @@ function registerHandlers(): void { handle(CHANNELS.entriesUpdate, async (id: never, patch: never) => { const { project, root } = requireProject(); + const previous = findEntry(id).displayName; const { project: updated, entry } = await updateEntry(root, project, id, patch); await session.setProject(updated); + + // Only the display name. A slot or settings change arrives from a control that already + // shows its own value, so putting it back is moving that control back. + if ((patch as { displayName?: unknown }).displayName) { + offerUndo('entry.renamedTitleDone', { name: entry.displayName.en }, async () => { + const current = requireProject(); + const reverted = await updateEntry(current.root, current.project, entry.id, { + displayName: previous, + }); + await session.setProject(reverted.project); + }); + } + return entry; }); @@ -252,13 +325,32 @@ function registerHandlers(): void { const { project, root } = requireProject(); const { project: updated, entry } = await renameEntry(root, project, id, newId); await session.setProject(updated); + + // renameEntry is a no-op when the id has not changed, and an undo for nothing would be + // a notification for nothing. + if (entry.id !== id) { + offerUndo('entry.renamedDone', { from: id, to: entry.id }, async () => { + const current = requireProject(); + const reverted = await renameEntry(current.root, current.project, entry.id, id); + await session.setProject(reverted.project); + }); + } + return entry; }); handle(CHANNELS.entriesDelete, async (id: never, deleteFiles: never) => { const { project, root } = requireProject(); - const updated = await deleteEntry(root, project, id, { deleteFiles }); + const { project: updated, entry, index } = await deleteEntry(root, project, id, { + deleteFiles, + }); await session.setProject(updated); + + offerUndo('entry.deletedDone', { id }, async () => { + const current = requireProject(); + const restored = await restoreEntry(current.root, current.project, entry, index); + await session.setProject(restored.project); + }); }); handle(CHANNELS.entriesPatchLive, async (id: never, settings: never) => { @@ -266,8 +358,11 @@ function registerHandlers(): void { // Persist first: the on-disk project is the source of truth even if the game is // not running or silently ignores a key. const { project, root } = requireProject(); - const { project: updated } = await updateEntry(root, project, id, { settings }); - await session.setProject(updated, { push: false }); + const { project: updated, entry: patched } = await updateEntry(root, project, id, { settings }); + // Settings cannot affect any slot but this entry's own, so only that one is rewritten. + // This runs once per slider tick — a full namespace rebuild here is what made the + // editor lag behind the control the user was dragging. + await session.setEntry(updated, patched); return session.patchEntrySettings({ ...entry, settings }, settings); }); @@ -307,8 +402,17 @@ function registerHandlers(): void { handle(CHANNELS.entriesRemoveTexture, async (id: never, key: never, deleteFile: never) => { const { project, root } = requireProject(); - const result = await removeTexture(root, project, findEntry(id), key, { deleteFile }); + const { removed, ...result } = await removeTexture(root, project, findEntry(id), key, { + deleteFile, + }); await session.pushAll(); + + offerUndo('texture.removedDone', { key }, async () => { + const current = requireProject(); + await restoreTexture(current.root, current.project, findEntry(id), removed); + await session.pushAll(); + }); + return result; }); @@ -324,6 +428,25 @@ function registerHandlers(): void { return readEntryPreviews(root, project); }); + handle(CHANNELS.entriesRemoveModelParent, async (id: never) => { + const { root } = requireProject(); + const removed = await removeModelParent(root, findEntry(id)); + if (removed === null) return null; + + // The slot redirect points at this file, so what the game loads changes with it. + await session.pushAll(); + + // Ella rewrote a file the author owns, so the way back is the file as it was — not a + // re-derived version of it, which would also undo whatever Blockbench formatted. + offerUndo('model.parentRemoved', { parent: removed.parent }, async () => { + const current = requireProject(); + await writeModelFile(current.root, findEntry(id), removed.original); + await session.pushAll(); + }); + + return removed.parent; + }); + handle(CHANNELS.entriesRevealTexture, () => { const { project, root } = requireProject(); shell.showItemInFolder( @@ -339,9 +462,24 @@ function registerHandlers(): void { await session.placeEntry(findEntry(id)); }); + handle(CHANNELS.undoRun, (token: never) => undoable.run(token)); + handle(CHANNELS.gameLaunch, async (versionId: never) => { await session.launch(versionId); await saveConfig({ lastVersion: versionId }); + + // A project with no version yet adopts the first one it is launched on. Asking instead + // would be a dialog whose only answer is the version already being launched, and it + // means every project made before this existed binds itself on its next run. + if (session.project && session.projectRoot && session.project.targetVersion === null) { + const { project } = await applyVersionChange( + session.projectRoot, + session.project, + versionId, + { migrate: false }, + ); + await session.setProject(project, { push: false }); + } }); handle(CHANNELS.gameStop, () => { @@ -372,6 +510,7 @@ function registerHandlers(): void { return defaultExportName(project); }); + handleRaw(CHANNELS.blockbenchResolve, () => resolveBlockbenchPath()); handleRaw(CHANNELS.blockbenchPluginStatus, () => pluginStatus()); handle(CHANNELS.blockbenchInstallPlugin, () => installPlugin()); @@ -450,7 +589,11 @@ function sessionStateDto() { // Lifecycle // --------------------------------------------------------------------------- -function createWindow(): void { +/** + * @param splash Closed once the main window can paint, or null when there is none. + * @param shownAt When the splash appeared, so it can be held for its minimum. + */ +function createWindow(splash: BrowserWindow | null = null, shownAt = 0): void { window = new BrowserWindow({ width: 1280, height: 840, @@ -458,7 +601,9 @@ function createWindow(): void { minHeight: 640, show: false, autoHideMenuBar: true, - backgroundColor: '#16161c', + // Matches --bg, so the frame that shows before the renderer paints is not a flash of + // a different colour. + backgroundColor: '#0f0f14', webPreferences: { preload: path.join(dirname, '../preload/index.mjs'), sandbox: false, @@ -467,7 +612,25 @@ function createWindow(): void { }, }); - window.once('ready-to-show', () => window?.show()); + const closeSplash = (): void => { + if (splash && !splash.isDestroyed()) splash.close(); + }; + + window.once('ready-to-show', () => { + const remaining = Math.max(0, MIN_SPLASH_MS - (Date.now() - shownAt)); + setTimeout(() => { + // The main window comes up first. Closing the splash first would leave a frame of + // bare desktop where the app should be. + window?.show(); + closeSplash(); + }, remaining); + }); + + // The splash is always-on-top and has no close button, so it must never be able to + // outlive a main window that failed to paint. + const failsafe = setTimeout(closeSplash, 20_000); + window.once('closed', () => clearTimeout(failsafe)); + window.webContents.once('did-fail-load', closeSplash); // Anything that is not the app itself belongs in the user's browser. window.webContents.setWindowOpenHandler(({ url }) => { @@ -485,6 +648,13 @@ function createWindow(): void { void app.whenReady().then(async () => { setDataRoot(app.getPath('userData')); + + // Up before anything slow runs. Reopening the last project regenerates its pack, which + // is most of the wait between clicking the icon and seeing a window. + const startupConfig = await loadConfig(); + const splash = createSplash(translate(startupConfig.locale, 'app.tagline')); + const splashShownAt = Date.now(); + await ensureLayout(); registerHandlers(); @@ -511,14 +681,13 @@ void app.whenReady().then(async () => { } // Reopen whatever was last in use, so the app starts where the user left off. - const config = await loadConfig(); - if (config.lastProject) { - await session.openProject(config.lastProject).catch(() => { + if (startupConfig.lastProject) { + await session.openProject(startupConfig.lastProject).catch(() => { // A project that was moved or deleted simply does not reopen. }); } - createWindow(); + createWindow(splash, splashShownAt); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); diff --git a/launcher/src/main/minecraft/forge.ts b/launcher/src/main/minecraft/forge.ts index 3394faa..a06c772 100644 --- a/launcher/src/main/minecraft/forge.ts +++ b/launcher/src/main/minecraft/forge.ts @@ -5,14 +5,25 @@ * That matters most from 1.13 onwards, where installation is not just unpacking files: * the installer runs binary patch and deobfuscation processors, and reproducing those * would mean tracking changes to a toolchain that is not ours. + * + * The installers of 2015 and 2016 have no headless client mode at all — `--installClient` + * was added later, and passing it to an older one aborts with "not a recognized option". + * Those builds also predate the processors, so their install genuinely is just unpacking + * files, and {@link installLegacyForge} does it directly. Which path applies is read off + * the installer's own `install_profile.json` rather than guessed from a version number: + * the old generation carries a `versionInfo` block, the new one carries `processors`. */ import { spawn } from 'node:child_process'; import { readFile, writeFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; -import { fetchJson, downloadFile } from './download.ts'; -import { ensureDir, getDataRoot, versionsDir, cacheDir } from '../paths.ts'; +import AdmZip from 'adm-zip'; +import { fetchJson, downloadFile, downloadAll } from './download.ts'; +import { ensureDir, getDataRoot, versionsDir, versionDir, librariesDir, cacheDir } from '../paths.ts'; import { selectJavaFor } from '../java-runtime.ts'; +import { mavenToPath, resolveLibraries } from './libraries.ts'; +import { libraryDownloadTasks } from './install.ts'; +import type { VersionJson } from './types.ts'; import { compareVersions } from '../../shared/version.ts'; const PROMOTIONS_URL = @@ -145,6 +156,107 @@ export interface ForgeInstallResult { versionId: string; } +/** + * The pre-2018 installer layout: a complete version document plus one jar to file away. + * + * `versionInfo` is written verbatim as the version json — it already carries + * `inheritsFrom`, so the vanilla document supplies everything it omits. `install.filePath` + * names the universal jar inside the installer, and `install.path` says where in + * `libraries/` it belongs. + */ +interface LegacyInstallProfile { + install: { filePath: string; path: string }; + versionInfo: { id: string } & Record; +} + +/** + * Whether an installer belongs to the generation Ella has to unpack itself. + * + * Exported for testing: getting this wrong in either direction is silent. Answering yes + * for a modern installer would skip the processors and produce a version that launches + * into a crash; answering no for an old one puts back the "not a recognized option" + * failure this exists to fix. + */ +export const isLegacyProfile = (value: unknown): value is LegacyInstallProfile => { + const profile = value as LegacyInstallProfile | null; + return ( + typeof profile === 'object' && + profile !== null && + typeof profile.install?.filePath === 'string' && + typeof profile.install?.path === 'string' && + typeof profile.versionInfo?.id === 'string' + ); +}; + +/** Reads `install_profile.json` out of an installer jar, or null when it has none. */ +function readInstallProfile(installerPath: string): unknown { + try { + const entry = new AdmZip(installerPath).getEntry('install_profile.json'); + return entry ? JSON.parse(entry.getData().toString('utf8')) : null; + } catch { + // A profile that cannot be read is not a legacy one; fall through to the installer, + // whose own error message will be more useful than anything invented here. + return null; + } +} + +/** + * Installs a pre-2018 Forge build by unpacking it, with no Java process involved. + * + * Three things make the version launchable: the version json, the universal jar in the + * place its own library entry points at, and every other library the json declares. + * + * That third step is not optional and is easy to overlook, because the official installer + * does it invisibly. Without it FML dies before the game window ever opens with + * `NoClassDefFoundError: org/objectweb/asm/ClassVisitor` — ASM is listed in the Forge json + * with no download url at all, so nothing else in the pipeline would ever fetch it. + * + * @returns the version id the game should be launched with + */ +async function installLegacyForge( + installerPath: string, + profile: LegacyInstallProfile, + onLog?: (line: string) => void, +): Promise { + const id = profile.versionInfo.id; + + const directory = await ensureDir(versionDir(id)); + await writeFile( + path.join(directory, `${id}.json`), + JSON.stringify(profile.versionInfo, null, 2), + 'utf8', + ); + + const zip = new AdmZip(installerPath); + const universal = zip.getEntry(profile.install.filePath); + if (!universal) { + throw new Error( + `The Forge installer is missing ${profile.install.filePath}, so ${id} cannot be installed`, + ); + } + + const target = path.join(librariesDir(), mavenToPath(profile.install.path)); + await ensureDir(path.dirname(target)); + await writeFile(target, universal.getData()); + + // Only the libraries this version file adds: the vanilla ones were fetched when the + // base version was installed, and anything already on disk is skipped by the downloader. + const version = profile.versionInfo as unknown as VersionJson; + const { classpath, natives } = resolveLibraries(version, librariesDir()); + const tasks = await libraryDownloadTasks([...classpath, ...natives]); + + const { failures } = await downloadAll(tasks, { concurrency: 8 }); + if (failures.length > 0) { + throw new Error( + `${failures.length} of ${tasks.length} Forge libraries could not be downloaded — ` + + `the first was ${failures[0].url}`, + ); + } + + onLog?.(`Fetched ${tasks.length} Forge libraries for ${id}`); + return id; +} + export async function installForge( mcVersion: string, options: { channel?: 'recommended' | 'latest'; onLog?: (line: string) => void } = {}, @@ -161,6 +273,17 @@ export async function installForge( ); await downloadFile({ url: build.installerUrl, destination: installerPath }); + // Old builds have no headless client install, so Ella does it itself. Checked against + // the installer's own profile rather than the Minecraft version: the change came with + // an installer generation, not with a game release, and 1.12.2 sits on the new side of + // it while 1.8.9 sits on the old one. + const profile = readInstallProfile(installerPath); + if (isLegacyProfile(profile)) { + options.onLog?.(`Installing Forge ${build.forgeVersion} directly (installer predates --installClient)`); + const versionId = await installLegacyForge(installerPath, profile, options.onLog); + return { build, versionId }; + } + const before = new Set( (await readdir(versionsDir(), { withFileTypes: true }).catch(() => [])) .filter((entry) => entry.isDirectory()) diff --git a/launcher/src/main/minecraft/install.ts b/launcher/src/main/minecraft/install.ts index 819fcf9..c2530b9 100644 --- a/launcher/src/main/minecraft/install.ts +++ b/launcher/src/main/minecraft/install.ts @@ -15,7 +15,7 @@ import { type ProgressCallback, type DownloadError, } from './download.ts'; -import { resolveLibraries } from './libraries.ts'; +import { resolveLibraries, type ResolvedLibrary } from './libraries.ts'; import { resolveVersionJson } from './manifest.ts'; import { assetIndexesDir, @@ -49,29 +49,25 @@ export interface InstallOptions { const assetObjectPath = (hash: string): string => path.join(assetObjectsDir(), hash.slice(0, 2), hash); -export async function installVersion( - id: string, - options: InstallOptions = {}, -): Promise { - const version = await resolveVersionJson(id); +/** + * Turns resolved libraries into download tasks. + * + * A library entry comes in three shapes and the difference is not cosmetic. Modern files + * give a direct url with a checksum. Forge's give a repository base and expect the path + * to be derived from the Maven coordinate. The oldest give neither and assume the + * launcher knows Mojang's own repository — that last group is why 1.8.x needs this at + * all, since `asm-all` and `trove4j` are listed with no url whatsoever. + * + * Exported because the legacy Forge install needs exactly this. Those installers leave + * every library to the launcher, and a second copy of the three-shape logic would drift + * from this one. + */ +export async function libraryDownloadTasks( + libraries: ResolvedLibrary[], +): Promise { const tasks: DownloadTask[] = []; - // --- client jar --------------------------------------------------------- - const client = version.downloads?.client; - if (client) { - tasks.push({ - url: client.url, - destination: versionJar(version.id), - sha1: client.sha1, - size: client.size, - label: `${version.id}.jar`, - }); - } - - // --- libraries and natives --------------------------------------------- - const { classpath, natives } = resolveLibraries(version, librariesDir()); - - for (const library of [...classpath, ...natives]) { + for (const library of libraries) { if (library.download?.url) { tasks.push({ url: library.download.url, @@ -96,6 +92,32 @@ export async function installVersion( } } + return tasks; +} + +export async function installVersion( + id: string, + options: InstallOptions = {}, +): Promise { + const version = await resolveVersionJson(id); + const tasks: DownloadTask[] = []; + + // --- client jar --------------------------------------------------------- + const client = version.downloads?.client; + if (client) { + tasks.push({ + url: client.url, + destination: versionJar(version.id), + sha1: client.sha1, + size: client.size, + label: `${version.id}.jar`, + }); + } + + // --- libraries and natives --------------------------------------------- + const { classpath, natives } = resolveLibraries(version, librariesDir()); + tasks.push(...(await libraryDownloadTasks([...classpath, ...natives]))); + // --- asset index and objects ------------------------------------------- let assetIndex: AssetIndex | undefined; let assetIndexId: string | undefined; diff --git a/launcher/src/main/minecraft/launch.ts b/launcher/src/main/minecraft/launch.ts index 40d4bb7..fe61957 100644 --- a/launcher/src/main/minecraft/launch.ts +++ b/launcher/src/main/minecraft/launch.ts @@ -8,6 +8,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { stat } from 'node:fs/promises'; import path from 'node:path'; import type { Argument, VersionJson } from './types.ts'; import { matchesRules } from './rules.ts'; @@ -24,9 +25,7 @@ import { } from '../paths.ts'; import { selectJavaFor, type JavaRuntime } from '../java-runtime.ts'; import { DEFAULT_PORT } from '../../shared/protocol.ts'; - -export const LAUNCHER_NAME = 'Ella'; -export const LAUNCHER_VERSION = '0.1.0'; +import { APP_NAME, APP_VERSION } from '../../shared/app.ts'; /** * Derives the UUID Minecraft itself uses for offline players: an RFC 4122 version 3 @@ -95,6 +94,42 @@ function collectArguments( return out; } +/** Raised before Java is spawned, so the cause is named rather than inferred. */ +export class MissingLibrariesError extends Error { + missing: string[]; + + constructor(missing: string[]) { + const names = missing.map((file) => path.basename(file)); + super( + `${missing.length} file(s) the game needs are missing, starting with ${names[0]}. ` + + 'Reinstall this version from the Versions tab to fetch them.', + ); + this.name = 'MissingLibrariesError'; + this.missing = missing; + } +} + +/** + * Refuses to launch with an incomplete classpath. + * + * A missing jar does not stop Java from starting; it surfaces much later as a + * `NoClassDefFoundError` deep inside the mod loader, naming a class rather than a file and + * pointing at no fix at all. That is how a Forge install which silently skipped its + * libraries presented itself. Checking here costs a few stat calls and turns a sixty-line + * stack trace into one sentence naming the file and what to do about it. + */ +async function requireClasspathPresent(entries: string[]): Promise { + const checks = await Promise.all( + entries.map(async (file) => ({ + file, + present: await stat(file).then((s) => s.isFile(), () => false), + })), + ); + + const missing = checks.filter((check) => !check.present).map((check) => check.file); + if (missing.length > 0) throw new MissingLibrariesError(missing); +} + export interface LaunchCommand { java: JavaRuntime; args: string[]; @@ -126,6 +161,8 @@ export async function buildLaunchCommand(options: LaunchOptions): Promise JSON.stringify({ parent: `${SLOT_NAMESPACE}:block/${slotModelName(slot)}` }, null, 2); /** A cube-shaped starting model, so a new block is visible before any Blockbench work. */ +/** + * A cube-shaped starting model, so a new block is visible before any Blockbench work. + * + * Self-contained rather than `{ parent: "block/cube_all" }`, and that is not a style + * choice. Blockbench keeps whatever parent it finds, so a model that starts with one still + * has it after geometry is added — and on 1.8.x a parent overrides the child's own + * elements outright, which renders the author's work as a plain cube. Starting with no + * parent means there is none to inherit into that trap. See shared/model-compat.ts. + * + * The elements and texture variables are what `block/cube_all` resolves to anyway, so + * nothing is lost on the versions where inheriting would have worked. + */ export const defaultBlockModel = (texture: string): string => - JSON.stringify({ parent: 'block/cube_all', textures: { all: texture } }, null, 2); + JSON.stringify( + { + textures: { + all: texture, + particle: texture, + }, + elements: [ + { + from: [0, 0, 0], + to: [16, 16, 16], + faces: { + down: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'down' }, + up: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'up' }, + north: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'north' }, + south: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'south' }, + west: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'west' }, + east: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'east' }, + }, + }, + ], + }, + null, + 2, + ); /** A flat sprite starting model for items. */ export const defaultItemModel = (texture: string, handheld: boolean): string => @@ -284,12 +319,87 @@ async function writeFileAt(root: string, relative: string, content: string | Buf await writeFile(target, content); } +/** + * Writes the files for one slot. + * + * `target` is the model the slot points at, or null for an unbound slot: those still need + * files, otherwise the game logs a missing-model error for every empty slot in the pool on + * every reload. + */ +async function writeSlot( + packDir: string, + kind: EntryKind, + slot: number, + target: string | null, + renderLayer: string | undefined, +): Promise { + if (kind === 'item') { + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, + slotRedirectModel(target ?? 'item/generated'), + ); + return; + } + + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/blockstates/${slotName(kind, slot)}.json`, + slotBlockstate(slot), + ); + + const redirect = slotRedirectModel(target, renderLayer); + + /* + * The same model is written at two paths on purpose. + * + * A blockstate's `model` value is resolved relative to `models/block/` on 1.12.2 + * and older — the prefix is implicit — but is a full path from `models/` on 1.13 + * and newer. So `ella:block/slot_000` means `models/block/block/slot_000.json` + * on the old versions and `models/block/slot_000.json` on the new ones. + * + * Writing both lets one blockstate serve the whole range. The alternative is + * emitting a different `model` value per version, which needs the target version + * at write time — and the pack is written before any game connects. + * + * Note this quirk applies only to blockstates. A `parent` inside a model file is + * a full path on every version, which is why the item models resolve correctly + * with a single copy. + */ + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/block/${slotModelName(slot)}.json`, + redirect, + ); + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/block/block/${slotModelName(slot)}.json`, + redirect, + ); + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, + blockItemModel(slot), + ); +} + +/** The model path a bound entry's slot redirects to. */ +const entryTarget = (project: EllaProject, entry: ProjectEntry): string => + `${project.namespace}:${entry.kind}/${entry.id}`; + +const entryRenderLayer = (entry: ProjectEntry): string | undefined => + typeof entry.settings.renderLayer === 'string' ? entry.settings.renderLayer : undefined; + /** * Regenerates the whole `ella` slot namespace from the project. * * The namespace is wiped first: stale blockstates from deleted entries would otherwise * keep rendering, which is exactly the kind of ghost that makes people restart the game * to "fix" something that was never broken. + * + * This is the expensive path — a full pool is four files per block slot plus one per item + * slot, so a default project regenerates several hundred files. Use it for changes that + * move bindings, names or the namespace; {@link writeEntrySlot} covers the rest. */ export async function writeSlotNamespace( projectRoot: string, @@ -310,61 +420,13 @@ export async function writeSlotNamespace( for (const kind of ['block', 'item'] as const) { for (let slot = 0; slot < project.slotPool[kind]; slot++) { const entry = bound.get(`${kind}:${slot}`); - // Unbound slots still need files, otherwise the game logs a missing-model error - // for every empty slot in the pool on every reload. - const target = entry - ? `${project.namespace}:${kind}/${entry.id}` - : null; - const renderLayer = - typeof entry?.settings.renderLayer === 'string' ? entry.settings.renderLayer : undefined; - - if (kind === 'block') { - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/blockstates/${slotName(kind, slot)}.json`, - slotBlockstate(slot), - ); - - const redirect = slotRedirectModel(target, renderLayer); - - /* - * The same model is written at two paths on purpose. - * - * A blockstate's `model` value is resolved relative to `models/block/` on 1.12.2 - * and older — the prefix is implicit — but is a full path from `models/` on 1.13 - * and newer. So `ella:block/slot_000` means `models/block/block/slot_000.json` - * on the old versions and `models/block/slot_000.json` on the new ones. - * - * Writing both lets one blockstate serve the whole range. The alternative is - * emitting a different `model` value per version, which needs the target version - * at write time — and the pack is written before any game connects. - * - * Note this quirk applies only to blockstates. A `parent` inside a model file is - * a full path on every version, which is why the item models resolve correctly - * with a single copy. - */ - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/block/${slotModelName(slot)}.json`, - redirect, - ); - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/block/block/${slotModelName(slot)}.json`, - redirect, - ); - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, - blockItemModel(slot), - ); - } else { - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, - slotRedirectModel(target ?? 'item/generated'), - ); - } + await writeSlot( + packDir, + kind, + slot, + entry ? entryTarget(project, entry) : null, + entry ? entryRenderLayer(entry) : undefined, + ); } } @@ -372,3 +434,30 @@ export async function writeSlotNamespace( await writeFileAt(packDir, `assets/${SLOT_NAMESPACE}/lang/${filename}`, content); } } + +/** + * Rewrites the files for a single entry's slot, leaving the rest of the namespace alone. + * + * Settings reach the generated pack through exactly one thing: the render layer, baked + * into that slot's redirect model. Nothing else in the namespace — blockstates, item + * models, the lang files — depends on an entry's settings, only on its binding and its + * display name. So a settings change is a handful of writes, not a regeneration. + * + * Returns false for an unbound entry, which has no slot files to write. + */ +export async function writeEntrySlot( + projectRoot: string, + project: EllaProject, + entry: ProjectEntry, +): Promise { + if (entry.slot === null) return false; + + await writeSlot( + path.join(projectRoot, 'pack'), + entry.kind, + entry.slot, + entryTarget(project, entry), + entryRenderLayer(entry), + ); + return true; +} diff --git a/launcher/src/main/project.ts b/launcher/src/main/project.ts index df7b166..91d0f25 100644 --- a/launcher/src/main/project.ts +++ b/launcher/src/main/project.ts @@ -9,6 +9,7 @@ import { PROJECT_FORMAT_VERSION, PROJECT_MANIFEST, emptyProject, + withDefaults, isValidIdentifier, nextFreeSlot, defaultModelOutput, @@ -19,12 +20,15 @@ import { } from '../shared/project.ts'; import type { EntryKind, LocaleMap } from '../shared/protocol.ts'; import { defaultsFor } from '../shared/settings-schema.ts'; +import { findParentTrap, withoutParent } from '../shared/model-compat.ts'; import { projectsDir, projectDir, ensureDir } from './paths.ts'; +import { stashFiles, restoreStash } from './trash.ts'; import { defaultBlockModel, defaultItemModel, placeholderTexturePng, writeSlotNamespace, + writeEntrySlot, HIGHEST_KNOWN_PACK_FORMAT, } from './pack.ts'; @@ -61,7 +65,7 @@ export async function loadProject(root: string): Promise { ); } - return project; + return withDefaults(project); } /** Writes the manifest atomically. */ @@ -77,6 +81,8 @@ export interface ProjectSummary { namespace: string; root: string; entryCount: number; + /** The version it is authored against, so the list can say so before it is opened. */ + targetVersion: string | null; } export async function listProjects(): Promise { @@ -94,6 +100,7 @@ export async function listProjects(): Promise { namespace: project.namespace, root, entryCount: project.entries.length, + targetVersion: project.targetVersion, }); } catch { // A directory that is not a project is simply not listed. @@ -105,7 +112,7 @@ export async function listProjects(): Promise { export async function createProject( name: string, namespace: string, - targetVersions: string[] = [], + targetVersion: string | null = null, ): Promise<{ project: EllaProject; root: string }> { if (!isValidIdentifier(namespace)) { throw new ProjectError( @@ -119,7 +126,7 @@ export async function createProject( throw new ProjectError('PROJECT_EXISTS', `A project already exists at ${root}`); } - const project = { ...emptyProject(name, namespace), targetVersions }; + const project = { ...emptyProject(name, namespace), targetVersion }; await ensureDir(path.join(root, 'sources')); await ensureDir(path.join(root, 'pack', 'assets', namespace)); @@ -147,7 +154,7 @@ export async function createProject( export async function updateProjectInfo( root: string, project: EllaProject, - changes: { name?: string; namespace?: string; targetVersions?: string[] }, + changes: { name?: string; namespace?: string; targetVersion?: string | null }, ): Promise { const name = changes.name?.trim() ?? project.name; const namespace = changes.namespace?.trim() ?? project.namespace; @@ -194,8 +201,16 @@ export async function updateProjectInfo( await rewriteNamespaceReferences(root, namespace, project.namespace, entries); } - const updated: EllaProject = { ...project, name, namespace, entries, - targetVersions: changes.targetVersions ?? project.targetVersions }; + const updated: EllaProject = { + ...project, + name, + namespace, + entries, + // Undefined means "leave it alone"; null is a deliberate unbinding, so the two cannot + // collapse into a single `??`. + targetVersion: + changes.targetVersion === undefined ? project.targetVersion : changes.targetVersion, + }; await saveProject(root, updated); return updated; @@ -473,30 +488,84 @@ export async function renameEntry( return { project: updated, entry: renamed }; } +/** The stash a deleted entry's files wait in. See main/trash.ts. */ +export const entryStash = (id: string): string => `entry-${id}`; + +export interface DeletedEntry { + project: EllaProject; + entry: ProjectEntry; + /** Where it sat in the list, so restoring it does not send it to the bottom. */ + index: number; +} + export async function deleteEntry( root: string, project: EllaProject, id: string, options: { deleteFiles?: boolean } = {}, -): Promise { - const entry = project.entries.find((candidate) => candidate.id === id); - if (!entry) throw new ProjectError('UNKNOWN_ENTRY', `No entry named "${id}"`); +): Promise { + const index = project.entries.findIndex((candidate) => candidate.id === id); + if (index === -1) throw new ProjectError('UNKNOWN_ENTRY', `No entry named "${id}"`); + const entry = project.entries[index]; const updated = { ...project, entries: project.entries.filter((candidate) => candidate.id !== id), }; if (options.deleteFiles) { - // Textures and Blockbench sources are the author's work; only remove them when the - // caller explicitly asks, so a mis-click cannot destroy an afternoon of modelling. - for (const relative of [entry.model.output, entry.model.path]) { - await rm(path.join(root, ...relative.split('/')), { force: true }); - } + // Textures and Blockbench sources are the author's work; only removed when the caller + // explicitly asks, and even then moved aside rather than destroyed, so the undo the + // editor offers afterwards has something to put back. + await stashFiles(root, entryStash(id), [ + entry.model.output, + entry.model.path, + textureRelativePath(project, entry), + ]); } await saveProject(root, updated); - return updated; + return { project: updated, entry, index }; +} + +/** + * Puts a deleted entry back, files and all. + * + * The slot is not restored blindly: an entry created in the meantime may have taken it, and + * two entries on one slot is a live-editing bug that would outlast this session. A taken + * slot is exchanged for the next free one, and a full pool leaves the entry unbound — + * recoverable by restarting the game, unlike a corrupted binding. + */ +export async function restoreEntry( + root: string, + project: EllaProject, + entry: ProjectEntry, + index: number, +): Promise<{ project: EllaProject; entry: ProjectEntry }> { + if (project.entries.some((candidate) => candidate.id === entry.id)) { + throw new ProjectError('DUPLICATE_ID', `An entry named "${entry.id}" already exists`); + } + + const taken = new Set( + project.entries + .filter((candidate) => candidate.kind === entry.kind && candidate.slot !== null) + .map((candidate) => candidate.slot as number), + ); + + const slot = + entry.slot !== null && !taken.has(entry.slot) ? entry.slot : nextFreeSlot(project, entry.kind); + + const restored: ProjectEntry = { ...entry, slot }; + const entries = [...project.entries]; + entries.splice(Math.min(index, entries.length), 0, restored); + + // Files first: a manifest that lists an entry whose model is still in the trash would + // describe a project that does not exist. + await restoreStash(root, entryStash(entry.id)); + + const updated = { ...project, entries }; + await saveProject(root, updated); + return { project: updated, entry: restored }; } /** Rebuilds the slot namespace after any change that affects bindings or names. */ @@ -508,6 +577,19 @@ export async function syncPack( await writeSlotNamespace(root, project, packFormat); } +/** + * Rewrites one entry's slot after a change that cannot affect any other slot. + * + * See {@link writeEntrySlot} for why a settings change qualifies. + */ +export async function syncEntryPack( + root: string, + project: EllaProject, + entry: ProjectEntry, +): Promise { + await writeEntrySlot(root, project, entry); +} + // --------------------------------------------------------------------------- // Textures // --------------------------------------------------------------------------- @@ -639,6 +721,52 @@ export async function readEntryPreviews( ); } +/** + * Removes the `parent` from an entry's model, so its own geometry is what renders. + * + * See {@link findParentTrap} for why this is needed at all. The rewrite is deliberately + * minimal — one key removed, everything else untouched, two-space JSON like Blockbench + * writes — because this is the author's file and the next Blockbench save has to see + * something it recognises. + * + * @returns the parent that was removed and the file as it was, or null when there was + * nothing to fix. The original is returned rather than kept aside because it is + * what lets the editor offer to take the rewrite back. + */ +export async function removeModelParent( + root: string, + entry: ProjectEntry, +): Promise<{ parent: string; original: string } | null> { + const file = path.join(root, ...entry.model.output.split('/')); + + let original: string; + let model: Record; + try { + original = await readFile(file, 'utf8'); + model = JSON.parse(original) as Record; + } catch (error) { + throw new ProjectError( + 'BAD_MODEL', + `Could not read the model for "${entry.id}": ${(error as Error).message}`, + ); + } + + const trap = findParentTrap(model); + if (!trap) return null; + + await writeFile(file, `${JSON.stringify(withoutParent(model), null, 2)}\n`, 'utf8'); + return { parent: trap.parent, original }; +} + +/** Writes an entry's model file back verbatim. The inverse of a rewrite Ella made. */ +export async function writeModelFile( + root: string, + entry: ProjectEntry, + content: string, +): Promise { + await writeFile(path.join(root, ...entry.model.output.split('/')), content, 'utf8'); +} + export async function writeProjectFile( root: string, relative: string, diff --git a/launcher/src/main/session.ts b/launcher/src/main/session.ts index fae21ba..dda6f3c 100644 --- a/launcher/src/main/session.ts +++ b/launcher/src/main/session.ts @@ -17,12 +17,53 @@ import { launchGame, type RunningGame } from './minecraft/launch.ts'; import { findForgeVersionId } from './minecraft/forge.ts'; import { ensureAdapterInstalled } from './adapters.ts'; import { collectCrashDiagnostics, collectLaunchFailureDiagnostics } from './diagnostics.ts'; -import { loadProject, saveProject, syncPack, type ProjectError } from './project.ts'; -import { HIGHEST_KNOWN_PACK_FORMAT } from './pack.ts'; +import { + loadProject, + saveProject, + syncPack, + syncEntryPack, + type ProjectError, +} from './project.ts'; +import { HIGHEST_KNOWN_PACK_FORMAT, SLOT_NAMESPACE } from './pack.ts'; +import { purgeStashes } from './trash.ts'; import { loadConfig } from './config.ts'; export type GameStatus = 'stopped' | 'starting' | 'running' | 'connected'; +/** + * How a game run ended. + * + * `stopped` the user pressed Stop — Ella killed it + * `quit` a clean exit from inside the game + * `terminated` killed by something else: Task Manager, the OS, a parent shell + * `failed` a non-zero exit, the only case with something to diagnose + */ +export type GameExit = + | { kind: 'stopped' } + | { kind: 'quit' } + | { kind: 'terminated'; signal: string | null } + | { kind: 'failed'; code: number }; + +/** + * Reads an exit the way a user would describe it. + * + * Killing a process reports a null exit code, which is the same shape as being killed by + * anything else and is *not* the same thing as failing. Ella used to test `code !== 0`, + * so pressing Stop logged an error and opened the crash dialog — the opposite of what the + * button promised. Whether the stop was requested is knowledge only the session has, which + * is why it is a parameter rather than something inferred here. + */ +export function classifyExit( + requested: boolean, + code: number | null, + signal: string | null, +): GameExit { + if (requested) return { kind: 'stopped' }; + if (code === 0) return { kind: 'quit' }; + if (code === null) return { kind: 'terminated', signal }; + return { kind: 'failed', code }; +} + /** How many output lines to retain for a crash report. */ const MAX_RETAINED_OUTPUT = 400; @@ -45,6 +86,8 @@ export class Session extends EventEmitter { private server: EllaServer; private watcher = new ModelWatcher(); private game: RunningGame | null = null; + /** Set by {@link stopGame}, so a deliberate stop is not reported as a failure. */ + private stopRequested = false; private status: GameStatus = 'stopped'; private reloadTimer: NodeJS.Timeout | null = null; /** Tail of the running game's output, kept for crash diagnostics. */ @@ -131,9 +174,18 @@ export class Session extends EventEmitter { this.project = project; this.projectRoot = root; + // Files a previous session moved aside for an undo that was never taken. The offers + // themselves are long gone, so this is the moment they stop costing disk. + await purgeStashes(root); + await syncPack(root, project, this.packFormat()); this.watcher.stop(); - await this.watcher.watchDirectory(path.join(root, 'pack')); + // The `ella` namespace is Ella's own output, regenerated from the project. Watching it + // meant every settings change fed its own writes back in as a model change: a reload + // the game did not need, and a preview refresh in the editor for each of them. + await this.watcher.watchDirectory(path.join(root, 'pack'), { + ignore: (relative) => relative.startsWith(`assets/${SLOT_NAMESPACE}/`), + }); this.emit('project', project); this.emit('state', this.state); @@ -168,6 +220,22 @@ export class Session extends EventEmitter { if (options.push !== false && this.server.connected) await this.pushAll(); } + /** + * Adopts a project whose only change is confined to one entry's own slot. + * + * The same as {@link setProject} except for what it rewrites: one slot instead of the + * whole namespace. Settings changes arrive one per slider tick, and regenerating several + * hundred files for each of them made the editor feel frozen while it caught up. + * + * The caller has already persisted the project — this does not save it again. + */ + async setEntry(project: EllaProject, entry: ProjectEntry): Promise { + if (!this.projectRoot) throw new Error('No project open'); + this.project = project; + await syncEntryPack(this.projectRoot, project, entry); + this.emit('project', project); + } + /** The connected game knows its own pack format; otherwise fall back to the table. */ private packFormat(): number { return this.server.game?.hello.packFormat ?? HIGHEST_KNOWN_PACK_FORMAT; @@ -183,6 +251,9 @@ export class Session extends EventEmitter { const config = await loadConfig(); this.versionId = versionId; + // Cleared per run: a previous process that ignored its kill signal must not hand its + // pending "this was deliberate" to whatever exits next. + this.stopRequested = false; this.setStatus('starting'); // Without Forge there is no Ella mod, so the game runs but nothing syncs. Launching @@ -239,36 +310,54 @@ export class Session extends EventEmitter { // Captured now: `this.game` is cleared before the crash report is built. const command = this.game.command; - this.game.process.on('exit', (code) => { + this.game.process.on('exit', (code, signal) => { + const exit = classifyExit(this.stopRequested, code, signal); + this.stopRequested = false; this.game = null; this.setStatus('stopped'); - // Exit code 0 is a normal quit. Anything else means the game failed, and the user - // should not have to go hunting through folders to find out why. - if (code !== 0) { + if (exit.kind === 'quit') return; + + if (exit.kind === 'stopped') { + this.emit('log', { level: 'info', source: 'game', message: 'Game stopped' }); + return; + } + + // Killed from outside. The game did not fault, so there is no crash report to + // collect and nothing to diagnose; say what happened and stop there. + if (exit.kind === 'terminated') { this.emit('log', { - level: 'error', + level: 'warn', source: 'game', - message: `Game exited with code ${code}`, + message: `Game was terminated${exit.signal ? ` (${exit.signal})` : ''}`, }); - - void collectCrashDiagnostics(versionId, code, this.recentOutput, { - javaUsed: { - major: command.java.major, - version: command.java.version, - path: command.java.path, - }, - launchedVersionId: command.version.id, - }) - .then((diagnostics) => this.emit('crash', diagnostics)) - .catch((error: Error) => - this.emit('log', { - level: 'error', - source: 'ella', - message: `Could not collect crash diagnostics: ${error.message}`, - }), - ); + return; } + + // A non-zero exit is a real failure, and the user should not have to go hunting + // through folders to find out why. + this.emit('log', { + level: 'error', + source: 'game', + message: `Game exited with code ${exit.code}`, + }); + + void collectCrashDiagnostics(versionId, exit.code, this.recentOutput, { + javaUsed: { + major: command.java.major, + version: command.java.version, + path: command.java.path, + }, + launchedVersionId: command.version.id, + }) + .then((diagnostics) => this.emit('crash', diagnostics)) + .catch((error: Error) => + this.emit('log', { + level: 'error', + source: 'ella', + message: `Could not collect crash diagnostics: ${error.message}`, + }), + ); }); } @@ -335,6 +424,10 @@ export class Session extends EventEmitter { } stopGame(): void { + // Recorded before the kill so the exit handler can tell "the user pressed Stop" from + // "the game died". Killing a process yields a null exit code, which is otherwise + // indistinguishable from a crash. + if (this.game) this.stopRequested = true; this.game?.process.kill(); this.game = null; this.setStatus('stopped'); diff --git a/launcher/src/main/splash.ts b/launcher/src/main/splash.ts new file mode 100644 index 0000000..b001c43 --- /dev/null +++ b/launcher/src/main/splash.ts @@ -0,0 +1,220 @@ +/** + * The window Ella shows while it starts. + * + * Startup is not instant — the data layout is checked, the IPC server binds a port, the + * last project is reopened and its pack is regenerated — and until the main window is + * ready to paint there is nothing on screen at all. A launcher that shows nothing for a + * second after its icon is clicked reads as one that failed to launch, and gets clicked + * again. + * + * The markup is a template string loaded as a `data:` URL rather than a file. It needs no + * bundler entry, nothing to copy at packaging time, and cannot fail to resolve a path + * inside an asar. Everything it draws is inline CSS and SVG: no fonts, no images, no + * scripts, so it paints on the first frame. + */ + +import { BrowserWindow } from 'electron'; + +/** + * Shortest time the splash stays up. + * + * The cube takes about this long to assemble, and a splash that vanishes mid-animation is + * worse than no splash. Startup usually outruns it anyway, so in practice this only + * matters on a warm second launch. + */ +export const MIN_SPLASH_MS = 1500; + +const HTML = ` + + + + + + +
+
+ + + + + + + + +
Ella
+
__TAGLINE__
+
+
+ +`; + +/** + * Opens the splash window. + * + * Transparent and frameless so the card can round its own corners, and `skipTaskbar` so + * Ella never shows up twice in the taskbar during startup. + */ +export function createSplash(tagline: string): BrowserWindow { + const splash = new BrowserWindow({ + width: 400, + height: 300, + frame: false, + transparent: true, + resizable: false, + movable: true, + center: true, + show: false, + skipTaskbar: true, + alwaysOnTop: true, + // No preload and no Node: this window renders one static page and talks to nothing. + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + + const html = HTML.replace('__TAGLINE__', escapeHtml(tagline)); + void splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + splash.once('ready-to-show', () => splash.show()); + + return splash; +} + +const escapeHtml = (value: string): string => + value.replace(/[&<>"]/g, (character) => { + switch (character) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + default: return '"'; + } + }); diff --git a/launcher/src/main/textures.ts b/launcher/src/main/textures.ts index e617fc3..bdf2d67 100644 --- a/launcher/src/main/textures.ts +++ b/launcher/src/main/textures.ts @@ -13,7 +13,7 @@ * touched the model. */ -import { readFile, writeFile, rm } from 'node:fs/promises'; +import { readFile, writeFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { EllaProject, ProjectEntry } from '../shared/project.ts'; import { placeholderTexturePng } from './pack.ts'; @@ -212,13 +212,24 @@ export async function addTexture( * not. Faces still pointing at the removed variable are reported so the caller can warn * rather than silently producing a model that will not load. */ +export interface RemovedTexture { + key: string; + reference: string; + /** True when removing it also cleared the model's `particle` entry. */ + wasParticle: boolean; +} + export async function removeTexture( root: string, project: EllaProject, entry: ProjectEntry, key: string, options: { deleteFile?: boolean } = {}, -): Promise<{ textures: TextureVariable[]; orphanedFaces: string[] }> { +): Promise<{ + textures: TextureVariable[]; + orphanedFaces: string[]; + removed: RemovedTexture; +}> { const model = await readModel(root, entry); const textures = model.textures ?? {}; @@ -232,9 +243,8 @@ export async function removeTexture( delete textures[key]; // A particle entry pointing at the removed file would outlive it. - if (key !== PARTICLE_KEY && textures[PARTICLE_KEY] === reference) { - delete textures[PARTICLE_KEY]; - } + const wasParticle = key !== PARTICLE_KEY && textures[PARTICLE_KEY] === reference; + if (wasParticle) delete textures[PARTICLE_KEY]; model.textures = textures; await writeModel(root, entry, model); @@ -248,9 +258,47 @@ export async function removeTexture( } } - return { textures: await listTextures(root, project, entry), orphanedFaces }; + return { + textures: await listTextures(root, project, entry), + orphanedFaces, + removed: { key, reference, wasParticle }, + }; } +/** + * Puts a removed texture variable back, particle entry included. + * + * Only the model JSON is rewritten, which is the whole of what {@link removeTexture} does + * when the image is kept — and the editor never deletes it. A variable whose file really + * was deleted is not restorable here, and is reported as such rather than restored as a + * reference to nothing, which is the one shape that stops a model loading at all. + */ +export async function restoreTexture( + root: string, + project: EllaProject, + entry: ProjectEntry, + removed: RemovedTexture, +): Promise { + const relativePath = resolveReference(project, removed.reference); + if (relativePath && !(await fileExists(path.join(root, ...relativePath.split('/'))))) { + throw new ProjectError( + 'TEXTURE_FILE_GONE', + `The image "${removed.reference}" points at was deleted, so the variable cannot be restored`, + ); + } + + const model = await readModel(root, entry); + model.textures ??= {}; + model.textures[removed.key] = removed.reference; + if (removed.wasParticle) model.textures[PARTICLE_KEY] = removed.reference; + + await writeModel(root, entry, model); + return listTextures(root, project, entry); +} + +const fileExists = (target: string): Promise => + stat(target).then((entry) => entry.isFile(), () => false); + /** Points the `particle` variable at the same file as `key`, or clears it. */ export async function setParticleTexture( root: string, diff --git a/launcher/src/main/trash.ts b/launcher/src/main/trash.ts new file mode 100644 index 0000000..096746a --- /dev/null +++ b/launcher/src/main/trash.ts @@ -0,0 +1,120 @@ +/** + * Files an action moved aside instead of deleting. + * + * Deleting an entry's model and texture is the one editor action whose result cannot be + * typed back in, and the notification that follows it offers a way out — which is only + * true if the bytes still exist. So "delete the files too" moves them into + * `/.trash//`, keeping their project-relative layout, which is what makes + * putting them back need no bookkeeping beyond the slug. + * + * The stash lives for as long as the project stays open: it is purged on the next open, so + * it cannot grow without limit. That is not a recycle bin and is not offered as one — the + * undo it backs is a notification that lasts seconds. + * + * It sits outside `pack/` on purpose, so nothing here reaches the file watcher, an export, + * or the running game. + */ + +import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; + +/** Kept out of `pack/`, and dot-prefixed so it reads as Ella's own bookkeeping. */ +export const TRASH_DIR = '.trash'; + +const stashDir = (root: string, slug: string): string => path.join(root, TRASH_DIR, slug); + +/** + * Moves files out of the project, into the stash named by `slug`. + * + * A path that does not exist is skipped rather than failing: an entry may never have had a + * Blockbench source, and refusing to delete it over that would be absurd. + * + * @returns the project-relative paths that were actually moved + */ +export async function stashFiles( + root: string, + slug: string, + relativePaths: string[], +): Promise { + const moved: string[] = []; + + for (const relative of relativePaths) { + const from = path.join(root, ...relative.split('/')); + const to = path.join(stashDir(root, slug), ...relative.split('/')); + + await mkdir(path.dirname(to), { recursive: true }); + try { + await rename(from, to); + moved.push(relative); + } catch { + // Missing, or already moved by an earlier call for the same entry. + } + } + + return moved; +} + +/** + * Puts a stash back where it came from and removes it. + * + * Never overwrites: a file that exists again was recreated after the delete, and it is the + * newer one. Losing it to an undo of something else would be the very failure this module + * exists to prevent. + * + * @returns the project-relative paths that were restored + */ +export async function restoreStash(root: string, slug: string): Promise { + const directory = stashDir(root, slug); + const restored: string[] = []; + + for (const relative of await listStashed(directory)) { + const from = path.join(directory, ...relative.split('/')); + const to = path.join(root, ...relative.split('/')); + + // `rename` replaces an existing destination silently, so it is checked first. + if (await stat(to).then(() => true, () => false)) continue; + + await mkdir(path.dirname(to), { recursive: true }); + try { + await rename(from, to); + restored.push(relative); + } catch { + // Left in the stash; the purge on the next project open clears it. + } + } + + await rm(directory, { recursive: true, force: true }); + return restored; +} + +/** Everything under a stash, as paths relative to it. */ +async function listStashed(directory: string, prefix = ''): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + + const files: string[] = []; + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listStashed(path.join(directory, entry.name), relative))); + } else { + files.push(relative); + } + } + return files; +} + +/** + * Empties the trash. + * + * Called when a project is opened rather than on a timer: the undo offers a stash backs are + * gone by then anyway, and tying the lifetime to something the user does keeps it + * predictable — nothing disappears while the project is open. + */ +export async function purgeStashes(root: string): Promise { + await rm(path.join(root, TRASH_DIR), { recursive: true, force: true }); +} diff --git a/launcher/src/main/undo.ts b/launcher/src/main/undo.ts new file mode 100644 index 0000000..52fa8d3 --- /dev/null +++ b/launcher/src/main/undo.ts @@ -0,0 +1,77 @@ +/** + * One-shot inverses, handed to the renderer as an opaque token. + * + * How to reverse an action belongs next to the action. The alternative — the renderer + * holding the manifest fragment, the file paths and the ordering it would need to undo a + * delete — would spread knowledge of the project format into the button that offers the + * undo, and would need a new IPC call for every action that ever becomes undoable. Here it + * is one `offer(...)` beside the thing that happened, and one channel for all of them. + * + * Offers are one-shot and bounded. They back a notification that lives for seconds; keeping + * more than the last handful would mean holding closures over a project that has since + * moved on, and an undo that quietly applies to the wrong state is worse than no undo. + */ + +import { randomUUID } from 'node:crypto'; + +export interface UndoOffer { + token: string; + /** i18n key describing what happened, translated by the renderer. */ + messageKey: string; + values: Record; +} + +/** + * How many offers stay live. + * + * Deep enough that a burst of edits does not drop the one still on screen, shallow enough + * that nothing lingers long past the notification it belongs to. + */ +const MAX_OFFERS = 8; + +export class UndoRegistry { + private inverses = new Map Promise>(); + + /** Registers a way back and returns what the renderer needs to offer it. */ + offer( + messageKey: string, + values: Record, + inverse: () => Promise, + ): UndoOffer { + const token = randomUUID(); + this.inverses.set(token, inverse); + + // Map iterates in insertion order, so the oldest is the first key. + while (this.inverses.size > MAX_OFFERS) { + const oldest = this.inverses.keys().next().value; + if (oldest === undefined) break; + this.inverses.delete(oldest); + } + + return { token, messageKey, values }; + } + + /** + * Runs an inverse, once. + * + * Removed before it runs rather than after: a failed undo has already done whatever part + * of its work it managed, and running it a second time would compound that rather than + * retry it. + */ + async run(token: string): Promise { + const inverse = this.inverses.get(token); + if (!inverse) { + const error = new Error('This change can no longer be undone') as Error & { code: string }; + error.code = 'UNDO_EXPIRED'; + throw error; + } + + this.inverses.delete(token); + await inverse(); + } + + /** Drops every offer. Used when the project they refer to is no longer open. */ + clear(): void { + this.inverses.clear(); + } +} diff --git a/launcher/src/main/version-change.ts b/launcher/src/main/version-change.ts new file mode 100644 index 0000000..b63918a --- /dev/null +++ b/launcher/src/main/version-change.ts @@ -0,0 +1,141 @@ +/** + * Moving an open project between Minecraft versions. + * + * Two halves: {@link planVersionChange} reads every entry's model and reports what would go + * wrong on the version about to be launched, and {@link applyVersionChange} rewrites those + * files and records the new version on the project. + * + * They are separate because the answer to "what will this break?" has to reach the user + * *before* anything is written — a migration that happened on the way to a dialog would be + * a migration nobody agreed to. What counts as broken, and how it is fixed, lives in + * shared/version-compat.ts; this module only does the file I/O around it. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { EllaProject } from '../shared/project.ts'; +import { + inspectModel, + migrateModel, + isVersionChange, + type CompatIssue, +} from '../shared/version-compat.ts'; +import { saveProject } from './project.ts'; + +export interface VersionChangeFinding extends CompatIssue { + entryId: string; +} + +export interface VersionChangePlan { + /** The version the project is bound to, or null when nothing has bound it yet. */ + from: string | null; + to: string; + /** True when this launch is a change the user should be asked about. */ + needsConfirmation: boolean; + findings: VersionChangeFinding[]; + /** How many model files Ella would rewrite — entries, not findings, since two issues in + * one model are still one file. */ + fixable: number; +} + +const modelPath = (root: string, output: string): string => + path.join(root, ...output.split('/')); + +/** Reads an entry's model, or null when it is missing or not JSON. */ +async function readModel( + root: string, + output: string, +): Promise | null> { + try { + const parsed = JSON.parse(await readFile(modelPath(root, output), 'utf8')) as unknown; + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : null; + } catch { + // A missing or malformed model is not a version problem, and validateForExport already + // reports it properly. Saying nothing here beats inventing a second diagnosis for it. + return null; + } +} + +/** + * What launching `to` would mean for this project. + * + * The findings are collected whether or not the version actually differs: the same check + * answers "is this file wrong for the version I am already on?", which is worth knowing. + */ +export async function planVersionChange( + root: string, + project: EllaProject, + to: string, +): Promise { + const findings: VersionChangeFinding[] = []; + + for (const entry of project.entries) { + const model = await readModel(root, entry.model.output); + if (!model) continue; + + for (const issue of inspectModel(model, to)) { + findings.push({ ...issue, entryId: entry.id }); + } + } + + const fixable = new Set( + findings.filter((finding) => finding.fixable).map((finding) => finding.entryId), + ); + + return { + from: project.targetVersion, + to, + needsConfirmation: isVersionChange(project.targetVersion, to), + findings, + fixable: fixable.size, + }; +} + +export interface VersionChangeResult { + project: EllaProject; + /** Entry ids whose model files were rewritten. */ + migrated: string[]; +} + +/** + * Binds the project to `to`, optionally rewriting the models that need it first. + * + * Files are written before the manifest: if a rewrite fails, the project still says it is + * bound to the version its files are actually written for. + * + * The rewrite keeps Blockbench's own formatting — two-space JSON with a trailing newline — + * because these are the author's files and the next save has to see something it + * recognises rather than a diff of the whole document. + */ +export async function applyVersionChange( + root: string, + project: EllaProject, + to: string, + options: { migrate: boolean }, +): Promise { + const migrated: string[] = []; + + if (options.migrate) { + for (const entry of project.entries) { + const model = await readModel(root, entry.model.output); + if (!model) continue; + + const { model: rewritten, applied } = migrateModel(model, to); + if (applied.length === 0) continue; + + await writeFile( + modelPath(root, entry.model.output), + `${JSON.stringify(rewritten, null, 2)}\n`, + 'utf8', + ); + migrated.push(entry.id); + } + } + + const updated: EllaProject = { ...project, targetVersion: to }; + await saveProject(root, updated); + + return { project: updated, migrated }; +} diff --git a/launcher/src/preload/index.ts b/launcher/src/preload/index.ts index e3c73b6..29c854e 100644 --- a/launcher/src/preload/index.ts +++ b/launcher/src/preload/index.ts @@ -40,14 +40,17 @@ const api: EllaApi = { projects: { list: () => invoke(CHANNELS.projectsList), - create: (name, namespace, targetVersions) => - invoke(CHANNELS.projectsCreate, name, namespace, targetVersions), + create: (name, namespace, targetVersion) => + invoke(CHANNELS.projectsCreate, name, namespace, targetVersion), open: (root) => invoke(CHANNELS.projectsOpen, root), current: () => invoke(CHANNELS.projectsCurrent), measure: () => invoke(CHANNELS.projectsMeasure), delete: (root) => invoke(CHANNELS.projectsDelete, root), close: () => invoke(CHANNELS.projectsClose), updateInfo: (changes) => invoke(CHANNELS.projectsUpdateInfo, changes), + planVersionChange: (versionId) => invoke(CHANNELS.projectsPlanVersionChange, versionId), + applyVersionChange: (versionId, migrate) => + invoke(CHANNELS.projectsApplyVersionChange, versionId, migrate), }, entries: { @@ -64,11 +67,16 @@ const api: EllaApi = { invoke(CHANNELS.entriesRemoveTexture, id, key, deleteFile), setParticleTexture: (id, key) => invoke(CHANNELS.entriesSetParticle, id, key), revealTexture: (id) => invoke(CHANNELS.entriesRevealTexture, id), + removeModelParent: (id) => invoke(CHANNELS.entriesRemoveModelParent, id), previews: () => invoke(CHANNELS.entriesPreviews), give: (id) => invoke(CHANNELS.entriesGive, id), place: (id) => invoke(CHANNELS.entriesPlace, id), }, + undo: { + run: (token) => invoke(CHANNELS.undoRun, token), + }, + game: { launch: (versionId) => invoke(CHANNELS.gameLaunch, versionId), stop: () => invoke(CHANNELS.gameStop), @@ -83,6 +91,7 @@ const api: EllaApi = { }, blockbench: { + resolve: () => invoke(CHANNELS.blockbenchResolve), pluginStatus: () => invoke(CHANNELS.blockbenchPluginStatus), installPlugin: () => invoke(CHANNELS.blockbenchInstallPlugin), }, @@ -105,6 +114,7 @@ const api: EllaApi = { progress: (handler) => subscribe(EVENTS.progress, handler), crash: (handler) => subscribe(EVENTS.crash, handler), files: (handler) => subscribe(EVENTS.files, handler), + undo: (handler) => subscribe(EVENTS.undo, handler), }, }; diff --git a/launcher/src/renderer/src/App.tsx b/launcher/src/renderer/src/App.tsx index ea6fcfd..a5d9a57 100644 --- a/launcher/src/renderer/src/App.tsx +++ b/launcher/src/renderer/src/App.tsx @@ -1,6 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useI18n } from './i18n.tsx'; import { useSession } from './session.ts'; +import { useWorkflowFacts } from './facts.ts'; +import { NAV, type View } from './navigation.ts'; +import { APP_VERSION } from '../../shared/app.ts'; +import { workflowSteps, completedCount } from '../../shared/workflow.ts'; +import { HomeView } from './views/HomeView.tsx'; import { VersionsView } from './views/VersionsView.tsx'; import { ProjectView } from './views/ProjectView.tsx'; import { EditorView } from './views/EditorView.tsx'; @@ -10,42 +15,95 @@ import { SettingsView } from './views/SettingsView.tsx'; import { StatusBar } from './components/StatusBar.tsx'; import { QuickLaunch } from './components/QuickLaunch.tsx'; import { CrashDialog } from './components/CrashDialog.tsx'; -import { Icon, type IconName } from './components/Icon.tsx'; - -type View = 'versions' | 'project' | 'editor' | 'logs' | 'export' | 'settings'; - -const NAV: Array<{ id: View; labelKey: string; icon: IconName }> = [ - { id: 'versions', labelKey: 'nav.versions', icon: 'versions' }, - { id: 'project', labelKey: 'nav.project', icon: 'project' }, - { id: 'editor', labelKey: 'nav.editor', icon: 'editor' }, - { id: 'logs', labelKey: 'nav.logs', icon: 'logs' }, - { id: 'export', labelKey: 'nav.export', icon: 'export' }, - { id: 'settings', labelKey: 'nav.settings', icon: 'settings' }, -]; +import { VersionChangeDialog } from './components/VersionChangeDialog.tsx'; +import { useToast } from './components/Toast.tsx'; +import { Icon } from './components/Icon.tsx'; export function App() { const { t } = useI18n(); + const toast = useToast(); const session = useSession(); - const [view, setView] = useState('project'); + const facts = useWorkflowFacts(session); + const [view, setView] = useState('home'); const [selectedEntry, setSelectedEntry] = useState(null); + /* + * Undo offers, subscribed once for the whole app. + * + * Main announces them rather than returning them, so the notification appears wherever + * the change was made — including changes nothing on screen asked for, like the rewrite + * that follows fixing a model. Which action it was is main's business; here it is a + * message and a token. + */ + useEffect( + () => + window.ella.on.undo((offer) => + toast.undoable(t(offer.messageKey, offer.values), () => + window.ella.undo.run(offer.token), + ), + ), + [toast, t], + ); + + const steps = workflowSteps(facts); + const remaining = steps.length - completedCount(steps); + /** Jumping straight to the editor is the common path after creating an entry. */ const openEntry = (id: string): void => { setSelectedEntry(id); setView('editor'); }; + // Ctrl+1…7 switches view. The shortcut is named in each item's tooltip rather than + // printed in the sidebar, which would cost a column of width to teach one thing once. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (!event.ctrlKey || event.altKey || event.shiftKey) return; + const index = Number(event.key) - 1; + const target = NAV[index]; + if (!target) return; + if (target.needsProject && !session.project) return; + event.preventDefault(); + setView(target.id); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [session.project]); + + /** Count or state for the view behind a row, or null when it has nothing to report. */ + const badgeFor = (id: View): { text: string; tone: string } | null => { + if (id === 'home' && remaining > 0) return { text: String(remaining), tone: 'attention' }; + if (id === 'versions' && facts.connected) return { text: t('home.live.on'), tone: 'live' }; + if (id === 'versions' && facts.installedVersions > 0) { + return { text: String(facts.installedVersions), tone: '' }; + } + if (id === 'project' && facts.entryCount > 0) { + return { text: String(facts.entryCount), tone: '' }; + } + return null; + }; + return (
- {view === 'versions' && } + {view === 'home' && ( + + )} + {view === 'versions' && } {view === 'project' && } {view === 'editor' && ( )} {view === 'logs' && } {view === 'export' && } - {view === 'settings' && } + {view === 'settings' && }
@@ -96,6 +174,18 @@ export function App() { {session.crash && ( )} + + {/* Rendered here rather than beside the button that triggered it: three different + views can start a launch, and the answer is the same wherever it came from. */} + {session.versionChange && session.project && ( + + )}
); } diff --git a/launcher/src/renderer/src/components/EmptyState.tsx b/launcher/src/renderer/src/components/EmptyState.tsx new file mode 100644 index 0000000..b4f7540 --- /dev/null +++ b/launcher/src/renderer/src/components/EmptyState.tsx @@ -0,0 +1,39 @@ +/** + * What a list shows before it has anything in it. + * + * An empty list is the moment a user is most likely to be stuck, so it is the worst place + * to print "nothing here" and stop. Each one names what is missing, says why it matters, + * and carries the button that fixes it — the same button they would otherwise have to go + * find on another tab. + */ + +import { Icon, type IconName } from './Icon.tsx'; + +interface Props { + icon: IconName; + title: string; + text?: string; + action?: { + label: string; + icon?: IconName; + onClick: () => void; + }; +} + +export function EmptyState({ icon, title, text, action }: Props) { + return ( +
+
+ +
+
{title}
+ {text &&
{text}
} + {action && ( + + )} +
+ ); +} diff --git a/launcher/src/renderer/src/components/EntryHeader.tsx b/launcher/src/renderer/src/components/EntryHeader.tsx index c4b8085..5785f57 100644 --- a/launcher/src/renderer/src/components/EntryHeader.tsx +++ b/launcher/src/renderer/src/components/EntryHeader.tsx @@ -25,7 +25,8 @@ interface Props { entry: ProjectEntry; namespace: string; preview: EntryPreviewDto | undefined; - onError: (message: string) => void; + /** Reports what went wrong, or null once it no longer applies. */ + onError: (message: string | null) => void; /** Called after a rename, so the caller can follow the entry to its new id. */ onRenamed: (newId: string) => void; } @@ -51,6 +52,9 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P const next = id.trim(); if (next === entry.id) return; + // Whatever went wrong last time was about the value being replaced. + onError(null); + if (!isValidIdentifier(next)) { setId(entry.id); onError(t('entry.idHelp')); @@ -84,6 +88,7 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P return; } + onError(null); const displayName = trimmedFr ? { en: trimmedEn, fr: trimmedFr } : { en: trimmedEn }; const result = await window.ella.entries.update(entry.id, { displayName }); if (!result.ok) onError(result.message); @@ -94,7 +99,7 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P {/* Left of the fields on purpose: while renaming or retyping a display name, the model is the thing that tells you which entry you are actually editing. */}
- +
diff --git a/launcher/src/renderer/src/components/ErrorBanner.tsx b/launcher/src/renderer/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..eace7a6 --- /dev/null +++ b/launcher/src/renderer/src/components/ErrorBanner.tsx @@ -0,0 +1,40 @@ +/** + * The failure of the last thing you asked for, shown where you asked for it. + * + * A toast would be wrong for these: unlike a confirmation, a failure often names something + * that still needs fixing on this page, and it should stay until it is read. But "stays" + * had become "stays forever" — the message outlived the state it described, sitting in red + * above an entry it no longer had anything to do with. + * + * So it can always be closed, and the pages that own one clear it when the thing it was + * about changes. A banner that cannot be dismissed is an assertion; this is a report. + */ + +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; + +interface Props { + /** Null renders nothing, so callers can hand over their error state directly. */ + message: string | null; + onDismiss: () => void; +} + +export function ErrorBanner({ message, onDismiss }: Props) { + const { t } = useI18n(); + if (!message) return null; + + return ( +
+ +
{message}
+ +
+ ); +} diff --git a/launcher/src/renderer/src/components/Icon.tsx b/launcher/src/renderer/src/components/Icon.tsx index 30d72a1..4009dff 100644 --- a/launcher/src/renderer/src/components/Icon.tsx +++ b/launcher/src/renderer/src/components/Icon.tsx @@ -11,6 +11,7 @@ */ export type IconName = + | 'home' | 'versions' | 'project' | 'editor' @@ -27,17 +28,27 @@ export type IconName = | 'folder' | 'refresh' | 'block' - | 'item'; + | 'item' + | 'check' + | 'arrow' + | 'info' + | 'alert' + | 'close' + | 'brush' + | 'download' + | 'bolt'; /** 24×24 viewBox, stroked, so every glyph scales and tints the same way. */ const PATHS: Record = { + home: 'M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z M9.5 21v-6h5v6', // A cube, drawn as a hexagon outline plus the three edges meeting at the front corner. versions: 'M12 2 21 7v10l-9 5-9-5V7z M12 12 21 7 M12 12v10 M12 12 3 7', block: 'M12 2 21 7v10l-9 5-9-5V7z M12 12 21 7 M12 12v10 M12 12 3 7', project: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', - // Sliders: three tracks with a handle on each. - editor: 'M4 6h16 M4 12h16 M4 18h16 M9 6v0 M15 12v0 M7 18v0', + // Sliders: three tracks, each with a handle drawn as a bar across it. Dots would be + // shorter to write but a zero-length segment renders as nothing at small sizes. + editor: 'M4 6h16 M4 12h16 M4 18h16 M9 4v4 M15 10v4 M7 16v4', logs: 'M4 5h16v14H4z M7 9l3 3-3 3 M13 15h4', export: 'M12 3v12 M8 11l4 4 4-4 M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2', settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2-1.2L14.2 3H9.8l-.4 2.7a7 7 0 0 0-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 2 1.2l.4 2.7h4.4l.4-2.7a7 7 0 0 0 2-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z', @@ -51,10 +62,19 @@ const PATHS: Record = { external: 'M14 4h6v6 M20 4l-8 8 M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5', refresh: 'M20 12a8 8 0 1 1-2.3-5.7 M20 4v4h-4', item: 'M12 3l8 4.5v9L12 21l-8-4.5v-9z M4 7.5l8 4.5 8-4.5 M12 12v9', + check: 'M5 12.5 10 17.5 19 7', + arrow: 'M5 12h13 M12.5 6l6 6-6 6', + info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M12 11v5 M12 8v0', + alert: 'M12 3.5 22 20H2z M12 10v4 M12 17v0', + close: 'M6 6l12 12 M18 6 6 18', + // A brush, standing for Blockbench — the tool Ella hands the model over to. + brush: 'M15.5 3.5 20.5 8.5 11 18l-5-5z M6 13l-2.5 6.5L10 17 M14 5l5 5', + download: 'M12 3v12 M7.5 10.5 12 15l4.5-4.5 M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2', + bolt: 'M13 2 4 14h7l-1 8 9-12h-7z', }; /** Glyphs whose shape reads better filled than stroked. */ -const FILLED: ReadonlySet = new Set(['play', 'stop']); +const FILLED: ReadonlySet = new Set(['play', 'stop', 'bolt']); interface Props { name: IconName; diff --git a/launcher/src/renderer/src/components/ModelPreview.tsx b/launcher/src/renderer/src/components/ModelPreview.tsx index 25aafa1..3d8f6fb 100644 --- a/launcher/src/renderer/src/components/ModelPreview.tsx +++ b/launcher/src/renderer/src/components/ModelPreview.tsx @@ -1,27 +1,77 @@ /** - * Canvas preview of a block model. + * Canvas preview of a block model, turnable with the pointer. * - * See model-preview.ts for the projection. This component owns only the drawing: loading - * the texture, mapping it onto each face, and shading. + * See model-preview.ts for the projection. This component owns the drawing — loading the + * texture, mapping it onto each face, shading — and the camera the user is dragging. + * + * Rotation is opt-in because the card grids put previews inside buttons, where a drag has + * to stay a click on the card rather than turning a thumbnail nobody asked to turn. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState, type PointerEvent, type KeyboardEvent } from 'react'; import { parseModel, drawOrder, - screenBounds, + fitScale, + modelCentre, + project, + turn, + DEFAULT_VIEW, FACE_SHADE, type ParsedModel, + type ViewAngles, } from '../../../shared/model-preview.ts'; import type { EntryPreviewDto } from '../../../shared/ipc.ts'; interface Props { preview: EntryPreviewDto | undefined; size: number; + /** Enables drag-to-rotate, and the keyboard equivalent. */ + interactive?: boolean; + /** Hint shown on hover, and the accessible name of the canvas. */ + label?: string; } -export function ModelPreview({ preview, size }: Props) { +/** Radians per pixel dragged. A full turn takes a little over half a screen width. */ +const DRAG_SPEED = 0.011; + +/** Radians per arrow key press: an eighth of a turn, so four presses show the far side. */ +const KEY_STEP = Math.PI / 8; + +export function ModelPreview({ preview, size, interactive = false, label }: Props) { const canvasRef = useRef(null); + const [view, setView] = useState(DEFAULT_VIEW); + const [texture, setTexture] = useState(null); + + const model = useMemo(() => (preview ? parseModel(preview.model) : null), [preview]); + // Fitting is view-independent by design, so it survives a rotation without recomputing. + const scale = useMemo(() => (model ? fitScale(model, size) : 0), [model, size]); + + // A different entry starts from the default angle: the camera belongs to the viewer's + // inspection of one model, not to the panel. + useEffect(() => { + setView(DEFAULT_VIEW); + }, [preview?.id]); + + // Held as state rather than loaded per draw, so dragging does not re-decode the texture + // on every pointer move. + useEffect(() => { + setTexture(null); + + const uri = preview?.textureDataUri; + if (!uri) return; + + let cancelled = false; + const image = new Image(); + image.onload = () => { + if (!cancelled) setTexture(image); + }; + image.src = uri; + + return () => { + cancelled = true; + }; + }, [preview?.textureDataUri]); useEffect(() => { const canvas = canvasRef.current; @@ -37,42 +87,106 @@ export function ModelPreview({ preview, size }: Props) { context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, size, size); - const model = preview ? parseModel(preview.model) : null; - if (!model) return; + // The texture may still be loading; the shape is drawn untextured until it arrives. + if (model) render(context, model, size, scale, view, texture); + }, [model, size, scale, view, texture]); + + const drag = useRef<{ pointerId: number; x: number; y: number } | null>(null); + + const onPointerDown = (event: PointerEvent): void => { + if (!interactive || !model) return; + // Stops the drag from also being read as a press on whatever contains the preview. + event.preventDefault(); + drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent): void => { + const current = drag.current; + if (!current || current.pointerId !== event.pointerId) return; + + const dx = event.clientX - current.x; + const dy = event.clientY - current.y; + current.x = event.clientX; + current.y = event.clientY; + + // Turntable feel: the surface under the pointer follows it, so the camera orbits the + // other way for yaw, and dragging down tips the top of the model towards the viewer. + setView((angles) => turn(angles, -dx * DRAG_SPEED, dy * DRAG_SPEED)); + }; + + const onPointerEnd = (event: PointerEvent): void => { + if (drag.current?.pointerId !== event.pointerId) return; + drag.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; - // The texture may still be loading; draw untextured first so the shape appears, then - // redraw with the image once it is ready. - render(context, model, size, null); + const onKeyDown = (event: KeyboardEvent): void => { + if (!interactive || !model) return; - if (!preview?.textureDataUri) return; - const image = new Image(); - image.onload = () => { - context.clearRect(0, 0, size, size); - render(context, model, size, image); + // Each key turns the model the way dragging in that direction would. + const step: Record = { + ArrowLeft: [KEY_STEP, 0], + ArrowRight: [-KEY_STEP, 0], + ArrowUp: [0, -KEY_STEP], + ArrowDown: [0, KEY_STEP], }; - image.src = preview.textureDataUri; - }, [preview, size]); - return ; + if (event.key === 'Home' || event.key === 'Escape') { + setView(DEFAULT_VIEW); + } else if (step[event.key]) { + const [yaw, pitch] = step[event.key]; + setView((angles) => turn(angles, yaw, pitch)); + } else { + return; + } + + // Arrow keys would otherwise scroll the panel out from under the preview. + event.preventDefault(); + }; + + return ( + interactive && setView(DEFAULT_VIEW)} + onKeyDown={onKeyDown} + /> + ); } function render( context: CanvasRenderingContext2D, model: ParsedModel, size: number, + scale: number, + view: ViewAngles, image: HTMLImageElement | null, ): void { - // Fit the model to the canvas with a small margin, whatever its extents. - const probe = screenBounds(model, 1); - const scale = (size * 0.86) / Math.max(probe.width, probe.height, 1); - - const bounds = screenBounds(model, scale); - const offsetX = size / 2 - (bounds.minX + bounds.maxX) / 2; - const offsetY = size / 2 - (bounds.minY + bounds.maxY) / 2; + // Centred on the model's own centre rather than on the bounding box of this angle, so + // rotating spins the model in place instead of sliding it around the canvas. + const centre = modelCentre(model); + const [centreX, centreY] = project(centre[0], centre[1], centre[2], scale, view); + const offsetX = size / 2 - centreX; + const offsetY = size / 2 - centreY; context.imageSmoothingEnabled = false; - for (const { element, face, geometry } of drawOrder(model, scale)) { + for (const { element, face, geometry } of drawOrder(model, scale, view)) { const uv = element.faces[face]?.uv; const [u1, v1, u2, v2] = uv && uv.length === 4 ? uv : [0, 0, 16, 16]; @@ -101,7 +215,7 @@ function render( context.fillRect(0, 0, 1.01, 1.01); } - // Fixed directional shading, standing in for the game's own face lighting. + // Vanilla's fixed per-direction face lighting. const shade = FACE_SHADE[face]; if (shade < 1) { context.fillStyle = `rgba(0, 0, 0, ${1 - shade})`; diff --git a/launcher/src/renderer/src/components/QuickLaunch.tsx b/launcher/src/renderer/src/components/QuickLaunch.tsx index 21da08b..6b9eb13 100644 --- a/launcher/src/renderer/src/components/QuickLaunch.tsx +++ b/launcher/src/renderer/src/components/QuickLaunch.tsx @@ -7,6 +7,11 @@ * * Populated from disk rather than the manifest, so it appears instantly and still works * with no network. + * + * The selection follows the open project rather than the last launch: a project is bound to + * the version it was authored for, and opening another one has to bring its version with + * it. Otherwise switching projects quietly leaves the previous project's version armed — + * one click from a run whose breakages are all silent. */ import { useEffect, useState } from 'react'; @@ -24,6 +29,7 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const { status, versionId } = session.state; const running = status !== 'stopped'; + const targetVersion = session.project?.targetVersion ?? null; useEffect(() => { void (async () => { @@ -41,6 +47,15 @@ export function QuickLaunch({ session }: { session: SessionHook }) { // Re-reads after a launch or an install/uninstall changes the session state. }, [status]); + // Opening a project arms its own version. Deliberately overwrites whatever was selected: + // the previous choice belonged to the previous project. + // + // Also re-arms it when a run ends, so the control comes to rest on the project's version + // rather than keeping a one-off launch armed for the next click. + useEffect(() => { + if (!running && targetVersion) setSelected(targetVersion); + }, [targetVersion, running]); + // Follow the running version, so the control always shows what is actually going on. useEffect(() => { if (running && versionId) setSelected(versionId); @@ -49,7 +64,7 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const launch = async (): Promise => { if (!selected) return; setBusy(true); - const result = await window.ella.game.launch(selected); + const result = await session.requestLaunch(selected); setError(result.ok ? null : result.message); setBusy(false); }; @@ -63,8 +78,19 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const current = versions.find((entry) => entry.id === selected); const canLaunch = Boolean(current?.javaAvailable) && !busy && selected !== ''; + // Two different situations, and the difference matters: the project's version is armed + // and something else was picked, or the project's version cannot be armed at all. + const targetInstalled = + targetVersion !== null && versions.some((entry) => entry.id === targetVersion); + const differsFromProject = targetVersion !== null && selected !== targetVersion; + return (
+
+ + {t('quick.title')} +
+ {error &&
{error}
} {versions.length === 0 ? ( @@ -85,24 +111,51 @@ export function QuickLaunch({ session }: { session: SessionHook }) { ))} + {/* The project's binding, said before the launch rather than in the dialog that + would follow it. Someone who picked another version on purpose gets a + reminder; someone who did it by accident gets a chance to notice. */} + {differsFromProject && ( +
+ + {targetInstalled + ? t('quick.projectTargets', { version: targetVersion }) + : t('quick.projectTargetsMissing', { version: targetVersion })} +
+ )} + {/* Says up front whether this launch will actually sync, rather than letting the user discover it from a silent absence of live editing. */} {current && current.adapterStatus !== 'built' && ( -
{t('quick.vanillaOnly')}
+
+ + {t('quick.vanillaOnly')} +
)} {current && !current.javaAvailable && (
+ {t('versions.javaMissing', { java: current.requiredJava })}
)} {running ? ( ) : ( - )} diff --git a/launcher/src/renderer/src/components/QuickNewEntry.tsx b/launcher/src/renderer/src/components/QuickNewEntry.tsx index 60559c4..3f90c76 100644 --- a/launcher/src/renderer/src/components/QuickNewEntry.tsx +++ b/launcher/src/renderer/src/components/QuickNewEntry.tsx @@ -2,44 +2,46 @@ * Compact entry creation, for the editor sidebar. * * The full form lives in the Project view; this exists so that adding a block during a - * modelling session does not mean leaving the editor. It asks for a name and nothing - * else — the identifier is derived, and the French name and settings are editable + * modelling session does not mean leaving the editor. It asks for a name and a kind and + * nothing else — the identifier is derived, and the French name and settings are editable * afterwards in the editor itself. + * + * The kind sits *inside* the composer rather than in front of it. Two buttons up front made + * the choice look like two separate features, and picking one swapped the whole row for a + * taller form, so the list below jumped every time. Here the panel opens once, at a fixed + * size, and the kind is a toggle you can still change while typing the name. */ import { useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import { slugify, isValidIdentifier } from '../../../shared/project.ts'; import type { EntryKind } from '../../../shared/protocol.ts'; interface Props { + /** Shown with the derived id, so the resource location is visible before creating. */ + namespace: string; /** Called with the new entry's id, so the caller can select it. */ onCreated: (id: string) => void; + onCancel: () => void; onError: (message: string) => void; } -export function QuickNewEntry({ onCreated, onError }: Props) { +export function QuickNewEntry({ namespace, onCreated, onCancel, onError }: Props) { const { t } = useI18n(); - const [kind, setKind] = useState(null); + const [kind, setKind] = useState('block'); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const inputRef = useRef(null); - // Focus as soon as the field appears: this is a keyboard-speed path, not a form. - useEffect(() => { - if (kind) inputRef.current?.focus(); - }, [kind]); + // Focus on open: this is a keyboard-speed path, not a form to fill in. + useEffect(() => inputRef.current?.focus(), []); const id = slugify(name); const valid = name.trim().length > 0 && isValidIdentifier(id); - const close = (): void => { - setKind(null); - setName(''); - }; - const create = async (): Promise => { - if (!valid || !kind || busy) return; + if (!valid || busy) return; setBusy(true); const result = await window.ella.entries.create({ @@ -53,27 +55,23 @@ export function QuickNewEntry({ onCreated, onError }: Props) { onError(result.message); return; } - close(); onCreated(result.value.id); }; - if (!kind) { - return ( -
- - -
- ); - } - return ( -
-
- {kind === 'block' ? t('entry.newBlock') : t('entry.newItem')} +
+
+ {(['block', 'item'] as EntryKind[]).map((candidate) => ( + + ))}
setName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void create(); - if (event.key === 'Escape') close(); + if (event.key === 'Escape') onCancel(); }} /> - {/* Shows what the identifier will be, since it is derived rather than typed. */} - {name.trim().length > 0 && ( -
- {valid ? id : t('entry.idHelp')} -
- )} + {/* The identifier is derived, not typed, so the resource location the game will see + is spelled out before anything is written. */} +
+ {name.trim().length === 0 ? '' : valid ? `${namespace}:${id}` : t('entry.idHelp')} +
- -
diff --git a/launcher/src/renderer/src/components/SettingsForm.tsx b/launcher/src/renderer/src/components/SettingsForm.tsx index 618d9c3..3c59ebf 100644 --- a/launcher/src/renderer/src/components/SettingsForm.tsx +++ b/launcher/src/renderer/src/components/SettingsForm.tsx @@ -4,9 +4,18 @@ * Fields the running adapter cannot honour are disabled and labelled with the reason, * never hidden — someone testing on 1.8.9 should still be able to see that an option * exists, and why it is unavailable to them. + * + * The controls are driven by a local draft rather than by the saved project. Every change + * has to travel to the main process, be written to disk and be pushed to the running game + * before it comes back, and a control bound to the round trip does not follow the mouse — + * dragging a slider looked like the window had frozen. The draft answers the keyboard and + * mouse immediately; saving happens behind it, coalesced and never overlapping, with a + * visible indicator so "behind" never means "silently". */ +import { useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import { fieldsFor, checkSettings, @@ -18,17 +27,29 @@ import { import type { EntryKind } from '../../../shared/protocol.ts'; interface Props { + /** Also the reset key: switching entry starts a fresh draft. */ + entryId: string; kind: EntryKind; settings: Record; capabilities: string[]; /** Null when no game is connected, so gating can be presented as unknown, not absent. */ minecraftVersion: string | null; - onChange: (patch: Record) => void; + /** Takes the entry id explicitly so a patch in flight cannot land on the wrong entry. */ + onChange: (entryId: string, patch: Record) => Promise; } const GROUPS: FieldGroup[] = ['appearance', 'physical', 'interaction']; +/** + * How long to wait after the last change before writing. + * + * Long enough that a slider drag is one write instead of forty, short enough that a + * checkbox still feels like it took effect at once. + */ +const DEBOUNCE_MS = 200; + export function SettingsForm({ + entryId, kind, settings, capabilities, @@ -36,25 +57,107 @@ export function SettingsForm({ onChange, }: Props) { const { t } = useI18n(); + const [draft, setDraft] = useState(settings); + const [saving, setSaving] = useState(false); + + /** What is waiting to be written, and for which entry — never assumed to be the current one. */ + const pending = useRef<{ entryId: string; patch: Record } | null>(null); + const timer = useRef | null>(null); + const flushing = useRef(false); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // A different entry is a different draft. Keyed on the id and not on `settings`, because + // our own write comes back through `settings` and would otherwise overwrite whatever the + // user has moved since. + useEffect(() => { + setDraft(settings); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entryId]); + + const flush = async (): Promise => { + // One writer at a time. Anything that arrives mid-write is picked up by the loop + // below rather than racing the request already in flight. + if (flushing.current) return; + + flushing.current = true; + setSaving(true); + try { + while (pending.current) { + const { entryId: target, patch } = pending.current; + pending.current = null; + await onChangeRef.current(target, patch); + } + } finally { + flushing.current = false; + setSaving(false); + } + }; + + const commit = (patch: Record): void => { + setDraft((current) => ({ ...current, ...patch })); + + pending.current = + pending.current && pending.current.entryId === entryId + ? { entryId, patch: { ...pending.current.patch, ...patch } } + : { entryId, patch }; + + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => { + timer.current = null; + void flush(); + }, DEBOUNCE_MS); + }; + + // Leaving the entry, or the view, must not drop an edit still inside the debounce + // window. The pending patch carries its own entry id, so writing it late is safe. + useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current); + if (pending.current) void flush(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entryId]); + const fields = fieldsFor(kind); - const warnings = checkSettings(kind, settings); + const warnings = checkSettings(kind, draft); + const busy = saving || pending.current !== null; const visible = (field: SettingField): boolean => - !field.visibleWhen || settings[field.visibleWhen.key] === field.visibleWhen.equals; + !field.visibleWhen || draft[field.visibleWhen.key] === field.visibleWhen.equals; return (
+
+

{t('entry.settings')}

+ + {/* Present only while there is something outstanding. A permanent "saved" chip + would be one more thing on screen that never changes. */} + {busy && ( + + + {t('common.saving')} + + )} +
+ {warnings.map((warning) => (
- {t(warning.messageKey)} - {warning.fix && ( - <> - {' '} - - - )} + +
+ {t(warning.messageKey)} + {warning.fix && ( + <> + {' '} + + + )} +
))} @@ -70,10 +173,10 @@ export function SettingsForm({ onChange({ [field.key]: value })} + onChange={(value) => commit({ [field.key]: value })} /> ))}
diff --git a/launcher/src/renderer/src/components/StatusBar.tsx b/launcher/src/renderer/src/components/StatusBar.tsx index d3f509f..5055bc3 100644 --- a/launcher/src/renderer/src/components/StatusBar.tsx +++ b/launcher/src/renderer/src/components/StatusBar.tsx @@ -1,4 +1,5 @@ import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import type { SessionHook } from '../session.ts'; export function StatusBar({ session }: { session: SessionHook }) { @@ -11,40 +12,54 @@ export function StatusBar({ session }: { session: SessionHook }) { const usedSlots = project?.entries.filter((entry) => entry.slot !== null).length ?? 0; const totalSlots = state.game ? state.game.slots.block + state.game.slots.item - : ((project?.slotPool.block ?? 0) + (project?.slotPool.item ?? 0)); + : (project?.slotPool.block ?? 0) + (project?.slotPool.item ?? 0); return (
- + - {connected ? t('game.connected') : running ? t('versions.launching') : t('game.disconnected')} + {connected + ? t('game.connected') + : running + ? t('versions.launching') + : t('game.disconnected')} {state.game && ( - + + {state.game.minecraftVersion} · {state.game.loader} · {state.game.adapter} )} {project && ( - + {project.name} · {t('game.slotsUsed', { used: usedSlots, total: totalSlots })} )} + {/* The label matters more than the bar: "install" and "download" take very different + amounts of time, and a bare bar cannot say which one is moving. */} {progress && ( - -
-
-
- - )} - {progress && ( - - {progress.completed} / {progress.total} - + <> + + {progress.label ?? t(`progress.${progress.phase}`)} + + +
+
+
+ + + {progress.completed} / {progress.total} + + )}
); diff --git a/launcher/src/renderer/src/components/TexturePanel.tsx b/launcher/src/renderer/src/components/TexturePanel.tsx index 554543a..e14dd8b 100644 --- a/launcher/src/renderer/src/components/TexturePanel.tsx +++ b/launcher/src/renderer/src/components/TexturePanel.tsx @@ -13,7 +13,12 @@ import type { TextureVariableDto } from '../../../shared/ipc.ts'; interface Props { entryId: string; - onError: (message: string) => void; + /** + * Reports what went wrong, or null once it no longer applies. Every operation clears it + * first: a message about the last one, still on screen after the next one worked, is + * read as a description of the state rather than of a moment. + */ + onError: (message: string | null) => void; } export function TexturePanel({ entryId, onError }: Props) { @@ -41,6 +46,7 @@ export function TexturePanel({ entryId, onError }: Props) { action: () => Promise<{ ok: true; value: TextureVariableDto[] | null } | { ok: false; message: string }>, ): Promise => { setBusy(true); + onError(null); const result = await action(); setBusy(false); @@ -56,6 +62,7 @@ export function TexturePanel({ entryId, onError }: Props) { if (!key) return; setBusy(true); + onError(null); const result = await window.ella.entries.addTexture(entryId, key); setBusy(false); @@ -69,6 +76,7 @@ export function TexturePanel({ entryId, onError }: Props) { }; const remove = async (texture: TextureVariableDto): Promise => { + onError(null); // The image is kept: the variable is one line of JSON to restore, the artwork is not. const result = await window.ella.entries.removeTexture(entryId, texture.key, false); if (!result.ok) { @@ -77,6 +85,8 @@ export function TexturePanel({ entryId, onError }: Props) { } setTextures(result.value.textures); + // Not a failure — the removal worked — but the model will not load until those faces + // are pointed somewhere else, which is worth more than a toast that scrolls away. if (result.value.orphanedFaces.length > 0) { onError( t('texture.orphanedFaces', { diff --git a/launcher/src/renderer/src/components/Toast.tsx b/launcher/src/renderer/src/components/Toast.tsx new file mode 100644 index 0000000..c6b0066 --- /dev/null +++ b/launcher/src/renderer/src/components/Toast.tsx @@ -0,0 +1,187 @@ +/** + * Transient confirmations, failures, and the way back out of a change. + * + * Inline banners were the wrong place for the first two. They render where the action was + * triggered, which by the time a launch or an export finishes is often scrolled out of view + * or on another tab entirely — so a success looked like nothing happening, and a failure + * could be missed completely. + * + * Failures stay noticeably longer than confirmations and can be dismissed by hand: a message + * you have to read should not disappear while you are reading it. + * + * An undoable toast is the third kind, and the reason it lives here rather than in a dialog: + * a change that needs confirming *before* it happens interrupts the work, while one that can + * be taken back afterwards does not. Its remaining time is drawn as a thinning line, because + * an offer with a deadline should show the deadline rather than vanish mid-reach. + */ + +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; + +type ToastKind = 'ok' | 'error' | 'info'; + +/** What an undo attempt reports back. Shaped to accept an IPC `Result` unchanged. */ +type UndoOutcome = { ok: boolean; message?: string }; + +interface ToastEntry { + id: number; + kind: ToastKind; + message: string; + undo?: () => Promise; + /** Set while the undo is in flight, so the button cannot be pressed twice. */ + undoing?: boolean; +} + +const LIFETIME_MS: Record = { + ok: 4000, + info: 5000, + error: 10000, +}; + +/** + * Longer than a plain confirmation: this one is not just read, it is decided on, and the + * decision needs time to reach the mouse. + */ +const UNDO_LIFETIME_MS = 9000; + +const ICONS: Record = { + ok: 'check', + error: 'alert', + info: 'info', +}; + +export interface ToastApi { + ok: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + /** + * Reports a change and offers to reverse it. + * + * The offer expires with the toast, but nothing is destroyed when it does: what expires + * is the shortcut, never the possibility. + */ + undoable: (message: string, undo: () => Promise) => void; +} + +const noop: ToastApi = { + ok: () => {}, + error: () => {}, + info: () => {}, + undoable: () => {}, +}; + +const ToastContext = createContext(noop); + +export function ToastProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const dismiss = useCallback((id: number): void => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const push = useCallback( + (entry: Omit, lifetime: number): void => { + const id = nextId.current++; + setToasts((current) => [...current, { ...entry, id }]); + setTimeout(() => dismiss(id), lifetime); + }, + [dismiss], + ); + + /** + * Guards against a double press landing two calls in one frame, before the disabled + * state has rendered. The second would find the offer already spent and report an error + * for something that in fact worked. + */ + const running = useRef(new Set()); + + const runUndo = useCallback( + async (toast: ToastEntry): Promise => { + if (!toast.undo || running.current.has(toast.id)) return; + running.current.add(toast.id); + + setToasts((current) => + current.map((candidate) => + candidate.id === toast.id ? { ...candidate, undoing: true } : candidate, + ), + ); + + const result = await toast.undo(); + running.current.delete(toast.id); + dismiss(toast.id); + + // Replaced rather than left in place: the offer is spent either way, and a toast + // still showing "Undo" after one was attempted invites a second press. + push( + result.ok + ? { kind: 'ok', message: t('common.undone') } + : { kind: 'error', message: result.message ?? t('common.error') }, + result.ok ? LIFETIME_MS.ok : LIFETIME_MS.error, + ); + }, + [dismiss, push, t], + ); + + const api = useMemo( + () => ({ + ok: (message) => push({ kind: 'ok', message }, LIFETIME_MS.ok), + error: (message) => push({ kind: 'error', message }, LIFETIME_MS.error), + info: (message) => push({ kind: 'info', message }, LIFETIME_MS.info), + undoable: (message, undo) => push({ kind: 'ok', message, undo }, UNDO_LIFETIME_MS), + }), + [push], + ); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ +
{toast.message}
+ + {toast.undo && ( + + )} + + + + {/* Only where there is something to lose by waiting. */} + {toast.undo && ( + + )} +
+ ))} +
+
+ ); +} + +export const useToast = (): ToastApi => useContext(ToastContext); diff --git a/launcher/src/renderer/src/components/VersionChangeDialog.tsx b/launcher/src/renderer/src/components/VersionChangeDialog.tsx new file mode 100644 index 0000000..6437935 --- /dev/null +++ b/launcher/src/renderer/src/components/VersionChangeDialog.tsx @@ -0,0 +1,170 @@ +/** + * Confirmation for launching a project on a version it was not authored for. + * + * A project is bound to a version, and most of the time that binding is invisible: the + * launcher preselects it and the run matches. This dialog is the exception, and it exists + * because the ways a resource pack breaks across versions are all silent — the game loads + * the file, says nothing, and draws the wrong thing. Finding out from a black block in the + * world is far worse than being told here. + * + * So it lists what would actually break, per entry, rather than warning in the abstract; + * and where Ella can rewrite the file itself, it offers to. + */ + +import { useState } from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; +import { ErrorBanner } from './ErrorBanner.tsx'; +import { versionChangeNotes, type VersionFacts } from '../../../shared/version-compat.ts'; +import type { Result, VersionChangePlanDto, VersionSummaryDto } from '../../../shared/ipc.ts'; + +interface Props { + plan: VersionChangePlanDto; + /** Installed versions, for the consequences that are not about the project's files. */ + versions: VersionSummaryDto[]; + projectName: string; + onCancel: () => void; + onConfirm: (adopt: boolean) => Promise>; +} + +export function VersionChangeDialog({ + plan, + versions, + projectName, + onCancel, + onConfirm, +}: Props) { + const { t } = useI18n(); + // Adopting is the answer that leaves the project consistent with its files, so it is the + // default. Unticking it makes the run a one-off, which is the rarer intent. + const [adopt, setAdopt] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const factsFor = (id: string | null): VersionFacts | null => { + if (!id) return null; + const summary = versions.find((version) => version.id === id); + return summary + ? { + id, + installed: summary.installed, + adapterStatus: summary.adapterStatus, + javaAvailable: summary.javaAvailable, + requiredJava: summary.requiredJava, + } + : null; + }; + + // A version absent from the installed list is one Ella cannot launch, which the notes + // then say in as many words rather than the dialog quietly showing nothing. + const target = factsFor(plan.to) ?? { + id: plan.to, + installed: false, + adapterStatus: null, + javaAvailable: true, + requiredJava: 0, + }; + + const notes = versionChangeNotes(factsFor(plan.from), target); + + const confirm = async (): Promise => { + setBusy(true); + const result = await onConfirm(adopt); + setBusy(false); + if (!result.ok) setError(result.message); + }; + + return ( +
+
event.stopPropagation()}> +
+
+
{t('compat.title')}
+
+ {t('compat.subtitle', { project: projectName, from: plan.from ?? '?', to: plan.to })} +
+
+ +
+ {plan.from} + + {plan.to} +
+
+ +
+ setError(null)} /> + + {notes.map((note) => ( +
+ +
{t(`compat.note.${note.id}`, note.detail)}
+
+ ))} + +

{t('compat.files')}

+ + {plan.findings.length === 0 ? ( +
+ +
{t('compat.noIssues', { to: plan.to })}
+
+ ) : ( +
+ {plan.findings.map((finding, index) => ( +
+ + {finding.entryId} + + {t(`compat.issue.${finding.issue}`, { ...finding.detail, to: plan.to })} + + {Number(finding.detail.count) > 1 && ( + {finding.detail.count} + )} + + + {finding.fixable ? t('compat.fixable') : t('compat.manual')} + +
+ ))} +
+ )} + +
+ {t('compat.automatic')} +
+
+ +
+ + + + + + +
+ + {/* Said last, next to the control it qualifies: unticking the box is not "cancel", + it is a run on another version with the project left where it is. */} + {!adopt && ( +
{t('compat.oneOff', { from: plan.from ?? '?' })}
+ )} +
+
+ ); +} diff --git a/launcher/src/renderer/src/facts.ts b/launcher/src/renderer/src/facts.ts new file mode 100644 index 0000000..e2de6b6 --- /dev/null +++ b/launcher/src/renderer/src/facts.ts @@ -0,0 +1,66 @@ +/** + * The facts the setup guide and the navigation both read. + * + * Gathered once in App and passed down rather than fetched per component: the installed + * version list is a disk scan, and three places asking for it independently would produce + * three different answers during an install. + */ + +import { useEffect, useState } from 'react'; +import type { WorkflowFacts } from '../../shared/workflow.ts'; +import type { VersionSummaryDto } from '../../shared/ipc.ts'; +import type { SessionHook } from './session.ts'; + +export interface Facts extends WorkflowFacts { + installed: VersionSummaryDto[]; + /** + * What a one-click launch should start: the open project's own version, then the last + * version launched if it is still installed, then the first one that can live-edit, then + * anything installed. + */ + preferredVersion: string | null; + /** Re-reads after an install, an uninstall, or a Blockbench path change. */ + refresh: () => void; +} + +export function useWorkflowFacts(session: SessionHook): Facts { + const [installed, setInstalled] = useState([]); + const [blockbenchFound, setBlockbenchFound] = useState(false); + const [lastVersion, setLastVersion] = useState(null); + const [nonce, setNonce] = useState(0); + + const { status } = session.state; + + useEffect(() => { + void window.ella.versions.installed().then((result) => { + if (result.ok) setInstalled(result.value); + }); + void window.ella.blockbench.resolve().then((path) => setBlockbenchFound(path !== null)); + void window.ella.config.get().then((config) => setLastVersion(config.lastVersion)); + // Launching writes lastVersion and an install changes the list, so the game's status + // is a good enough signal to re-read on without polling. + }, [status, nonce]); + + const liveCapable = installed.filter((version) => version.adapterStatus === 'built'); + + // The project's version outranks the last one launched: the guide's Launch button is a + // one-click path, and it should not be the thing that starts a version change. + const preferred = + installed.find((version) => version.id === session.project?.targetVersion) ?? + installed.find((version) => version.id === lastVersion) ?? + liveCapable[0] ?? + installed[0] ?? + null; + + return { + installed, + installedVersions: installed.length, + liveEditingVersions: liveCapable.length, + hasProject: session.project !== null, + entryCount: session.project?.entries.length ?? 0, + blockbenchFound, + connected: status === 'connected', + preferredVersion: preferred?.id ?? null, + refresh: () => setNonce((current) => current + 1), + }; +} diff --git a/launcher/src/renderer/src/main.tsx b/launcher/src/renderer/src/main.tsx index aa8acaa..c24b63e 100644 --- a/launcher/src/renderer/src/main.tsx +++ b/launcher/src/renderer/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { I18nProvider } from './i18n.tsx'; +import { ToastProvider } from './components/Toast.tsx'; import { App } from './App.tsx'; import './styles.css'; @@ -10,7 +11,9 @@ if (!container) throw new Error('Root element is missing from index.html'); createRoot(container).render( - + + + , ); diff --git a/launcher/src/renderer/src/navigation.ts b/launcher/src/renderer/src/navigation.ts new file mode 100644 index 0000000..8c83d30 --- /dev/null +++ b/launcher/src/renderer/src/navigation.ts @@ -0,0 +1,32 @@ +/** + * The view list, shared by App and by anything that needs to send the user somewhere. + * + * Kept out of App.tsx so the home view can navigate without importing its own parent. + */ + +import type { IconName } from './components/Icon.tsx'; + +export type View = 'home' | 'versions' | 'project' | 'editor' | 'logs' | 'export' | 'settings'; + +export interface NavItem { + id: View; + labelKey: string; + icon: IconName; + /** Nothing on this view works without a project open. */ + needsProject?: boolean; +} + +/* + * Ordered as the work is done, not alphabetically or by importance: home, then the setup + * (a version, a project), then the loop (editing, watching the log), then what comes at + * the end (export) and what you rarely touch (settings). + */ +export const NAV: NavItem[] = [ + { id: 'home', labelKey: 'nav.home', icon: 'home' }, + { id: 'versions', labelKey: 'nav.versions', icon: 'versions' }, + { id: 'project', labelKey: 'nav.project', icon: 'project' }, + { id: 'editor', labelKey: 'nav.editor', icon: 'editor', needsProject: true }, + { id: 'logs', labelKey: 'nav.logs', icon: 'logs' }, + { id: 'export', labelKey: 'nav.export', icon: 'export', needsProject: true }, + { id: 'settings', labelKey: 'nav.settings', icon: 'settings' }, +]; diff --git a/launcher/src/renderer/src/session.ts b/launcher/src/renderer/src/session.ts index f19dcc7..a66c648 100644 --- a/launcher/src/renderer/src/session.ts +++ b/launcher/src/renderer/src/session.ts @@ -5,7 +5,13 @@ import { useEffect, useState } from 'react'; import type { EllaProject } from '../../shared/project.ts'; import type { LogPayload } from '../../shared/protocol.ts'; -import type { ProgressDto, SessionStateDto, CrashDiagnosticsDto } from '../../shared/ipc.ts'; +import type { + ProgressDto, + Result, + SessionStateDto, + CrashDiagnosticsDto, + VersionChangePlanDto, +} from '../../shared/ipc.ts'; const EMPTY_STATE: SessionStateDto = { status: 'stopped', @@ -30,6 +36,7 @@ export function useSession() { const [lines, setLines] = useState([]); const [progress, setProgress] = useState(null); const [crash, setCrash] = useState(null); + const [versionChange, setVersionChange] = useState(null); useEffect(() => { let nextKey = 0; @@ -56,12 +63,69 @@ export function useSession() { return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); }, []); + // A pending version question belongs to the project that raised it. Keyed on the root + // rather than on the project object, which is replaced by every edit — including the + // rebind the question itself performs. + useEffect(() => { + setVersionChange(null); + }, [state.projectRoot]); + + /** + * The one way to start the game. + * + * Every launch goes through here so the version check cannot be bypassed by whichever + * button happens to be nearest — the sidebar, the versions list and the setup guide all + * start a run, and a guard on one of them is a guard on none. + * + * Resolves ok when the launch was deferred to the confirmation: a question is not a + * failure, and the dialog owns what happens next. + */ + const requestLaunch = async (versionId: string): Promise> => { + if (project) { + const plan = await window.ella.projects.planVersionChange(versionId); + if (plan.ok && plan.value.needsConfirmation) { + setVersionChange(plan.value); + return { ok: true, value: undefined }; + } + } + return window.ella.game.launch(versionId); + }; + + /** + * Answers the confirmation and launches. + * + * `adopt` rebinds the project to the new version and rewrites the models that need it; + * without it the run is a one-off and the project keeps pointing at the version it was + * authored for — so the same warning comes back next time, which is the point of binding + * a project to a version at all. + */ + const confirmVersionChange = async (adopt: boolean): Promise> => { + if (!versionChange) return { ok: true, value: undefined }; + const { to } = versionChange; + + // The rebind stands even if the launch then fails: moving the project was the answer + // given, and Java being missing does not retract it. The dialog stays open with the + // error, so the launch can be retried without being asked the same question again. + if (adopt) { + const applied = await window.ella.projects.applyVersionChange(to, true); + if (!applied.ok) return applied; + } + + const launched = await window.ella.game.launch(to); + if (launched.ok) setVersionChange(null); + return launched; + }; + return { state, project, lines, progress, crash, + versionChange, + requestLaunch, + confirmVersionChange, + cancelVersionChange: () => setVersionChange(null), dismissCrash: () => setCrash(null), clearLines: () => setLines([]), hasCapability: (capability: string) => state.capabilities.includes(capability), diff --git a/launcher/src/renderer/src/styles.css b/launcher/src/renderer/src/styles.css index 98641a5..98a31c6 100644 --- a/launcher/src/renderer/src/styles.css +++ b/launcher/src/renderer/src/styles.css @@ -1,17 +1,62 @@ +/* + * Ella's design system. + * + * Three rules hold the look together, and everything below follows from them: + * + * Depth by layer, not by line. Surfaces step up (--surface-1 → 3) as they come forward. + * Borders separate peers; elevation separates planes. + * + * One accent, spent carefully. Violet marks the thing to do next and nothing else, so a + * glance at any screen finds the primary action without reading. + * + * Motion only where it explains. Panels rise as they arrive because they came from + * somewhere; a live indicator pulses because it is live. Nothing animates for decoration, + * and all of it stops under prefers-reduced-motion. + */ + :root { - --bg: #16161c; - --bg-raised: #1e1e26; - --bg-input: #26262f; - --border: #33333f; - --text: #e6e6ec; - --text-dim: #9a9aab; - --text-faint: #6b6b7b; - --accent: #7c6cf5; - --accent-dim: #5b4fd0; - --ok: #3fbf7f; - --warn: #e0a02c; - --error: #e05252; - --radius: 6px; + /* Surfaces, back to front. */ + --bg: #0f0f14; + --surface-1: #16161d; + --surface-2: #1c1c26; + --surface-3: #23232f; + --surface-inset: #101015; + + --border: #2b2b38; + --border-soft: #22222d; + --border-strong: #3b3b4c; + + --text: #eeeef3; + --text-dim: #a4a4b8; + --text-faint: #6e6e84; + + --accent: #8b7bff; + --accent-bright: #a99bff; + --accent-deep: #6a58e8; + --accent-soft: rgba(139, 123, 255, 0.14); + --accent-line: rgba(139, 123, 255, 0.32); + + --ok: #45c98a; + --ok-soft: rgba(69, 201, 138, 0.13); + --warn: #e5a63a; + --warn-soft: rgba(229, 166, 58, 0.13); + --error: #ec5f5f; + --error-soft: rgba(236, 95, 95, 0.13); + + --radius-sm: 6px; + --radius: 9px; + --radius-lg: 14px; + --radius-pill: 999px; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 6px 20px -6px rgba(0, 0, 0, 0.55); + --shadow-3: 0 24px 60px -12px rgba(0, 0, 0, 0.7); + --glow: 0 6px 22px -8px rgba(139, 123, 255, 0.75); + + --fast: 110ms; + --base: 180ms; + --slow: 300ms; + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); } * { @@ -20,118 +65,260 @@ body { margin: 0; - font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Inter', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif; font-size: 14px; + line-height: 1.5; color: var(--text); background: var(--bg); overflow: hidden; user-select: none; + /* Text at this size reads muddy without it on Windows. */ + -webkit-font-smoothing: antialiased; } +/* ---------------------------------------------------------------- controls */ + button { font: inherit; - color: inherit; - background: var(--bg-input); + font-weight: 500; + color: var(--text); + background: var(--surface-3); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 6px 12px; + border-radius: var(--radius-sm); + padding: 6px 13px; cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + transition: + background var(--fast) var(--ease), + border-color var(--fast) var(--ease), + transform var(--fast) var(--ease), + box-shadow var(--fast) var(--ease); } button:hover:not(:disabled) { - border-color: var(--accent); + background: var(--border); + border-color: var(--border-strong); +} +button:active:not(:disabled) { + transform: translateY(1px); } button:disabled { - opacity: 0.4; + opacity: 0.42; cursor: not-allowed; } + button.primary { - background: var(--accent); - border-color: var(--accent); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-deep) 100%); + border-color: var(--accent-deep); color: #fff; + box-shadow: var(--shadow-1); } button.primary:hover:not(:disabled) { - background: var(--accent-dim); + background: linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 100%); + border-color: var(--accent); + box-shadow: var(--glow); } + +button.subtle { + background: transparent; + border-color: transparent; + color: var(--text-dim); +} +button.subtle:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} + button.danger:hover:not(:disabled) { border-color: var(--error); + background: var(--error-soft); color: var(--error); } +button.link { + background: none; + border: none; + padding: 0; + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} +button.link:hover:not(:disabled) { + color: var(--accent-bright); +} + +/* Square button holding an icon alone. */ +button.icon-only { + padding: 6px; + aspect-ratio: 1; +} + +button svg:not(:only-child) { + margin-right: -1px; +} + input, -select { +select, +textarea { font: inherit; color: inherit; - background: var(--bg-input); + background: var(--surface-inset); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 5px 8px; + border-radius: var(--radius-sm); + padding: 6px 9px; width: 100%; user-select: text; + transition: + border-color var(--fast) var(--ease), + box-shadow var(--fast) var(--ease); +} +input::placeholder { + color: var(--text-faint); +} +input:hover:not(:disabled), +select:hover:not(:disabled) { + border-color: var(--border-strong); } input:focus, -select:focus { +select:focus, +textarea:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } input[type='checkbox'] { width: auto; accent-color: var(--accent); + cursor: pointer; } input[type='range'] { padding: 0; accent-color: var(--accent); + background: none; + border: none; + cursor: pointer; +} +input[type='range']:focus { + box-shadow: none; +} + +/* Keyboard users get a ring on everything; mouse users never see it. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button:focus-visible { + outline-offset: 1px; +} + +/* Scrollbars, matched to the surfaces they sit on. */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border: 3px solid transparent; + background-clip: content-box; + border-radius: var(--radius-pill); +} +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); + background-clip: content-box; } /* ---------------------------------------------------------------- layout */ .app { display: grid; - grid-template-columns: 190px 1fr; + grid-template-columns: 218px 1fr; grid-template-rows: 1fr auto; height: 100vh; + /* A single light source behind the whole app; the sidebar and cards read as lit from + the top left rather than as flat blocks of colour. */ + background: + radial-gradient(1100px 620px at 8% -8%, rgba(139, 123, 255, 0.09), transparent 60%), + var(--bg); } .sidebar { grid-row: 1 / 3; - background: var(--bg-raised); - border-right: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface-1) 0%, rgba(18, 18, 24, 0.92) 100%); + border-right: 1px solid var(--border-soft); display: flex; flex-direction: column; - padding: 14px 10px; - gap: 4px; + padding: 16px 12px 12px; + gap: 2px; + z-index: 1; } .brand { - font-size: 18px; - font-weight: 600; - padding: 4px 8px 14px; - letter-spacing: 0.4px; + display: flex; + align-items: center; + gap: 9px; + font-size: 17px; + font-weight: 650; + letter-spacing: 0.2px; + padding: 2px 6px 16px; +} +.brand-mark { + width: 26px; + height: 26px; + border-radius: 8px; + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + display: grid; + place-items: center; + color: #fff; + box-shadow: var(--glow); + flex: 0 0 auto; } -.brand span { - color: var(--accent); +.brand-text span { + color: var(--accent-bright); +} + +/* Two steps quieter than the wordmark: the version is there to be read once, not to share + billing with the name. Selectable, because its whole purpose is being copied into a + bug report. */ +.brand-version { + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-faint); + margin-left: -3px; + user-select: text; } .project-chip { display: block; width: 100%; text-align: left; - background: var(--bg-input); + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); - padding: 7px 10px; - margin-bottom: 12px; + padding: 8px 11px; + margin-bottom: 14px; line-height: 1.35; } .project-chip:hover { - border-color: var(--accent); + background: var(--surface-3); + border-color: var(--accent-line); } -.project-chip.empty { +/* Not `.empty`: that class is the full-page empty state, and its 40px padding and centred + text were landing on this button too. */ +.project-chip.vacant { border-style: dashed; + color: var(--text-faint); } .project-chip-name { display: block; - font-weight: 500; + font-weight: 550; /* Long project names must not widen the sidebar. */ overflow: hidden; text-overflow: ellipsis; @@ -150,60 +337,116 @@ input[type='range'] { .nav-item { display: flex; align-items: center; - gap: 9px; + gap: 10px; + width: 100%; text-align: left; + justify-content: flex-start; background: none; border: none; - padding: 8px 10px; - border-radius: var(--radius); + padding: 8px 11px; + border-radius: var(--radius-sm); color: var(--text-dim); + font-weight: 500; + position: relative; } - -/* Icons sit slightly dimmer than their label until the row is active, so the text stays - the thing you read first. */ +.nav-item:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--text); +} +/* The active marker is drawn rather than bordered so it can round its own ends. */ +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 17px; + border-radius: var(--radius-pill); + background: var(--accent-bright); +} + +/* Icons sit dimmer than their label until the row is active, so the text stays the thing + you read first. */ .nav-item svg { - opacity: 0.7; + opacity: 0.75; flex: 0 0 auto; } -.nav-item.active svg, +.nav-item.active svg { + opacity: 1; + color: var(--accent-bright); +} .nav-item:hover svg { opacity: 1; } -/* Any button holding an icon and a label lines them up the same way. */ -button svg { - vertical-align: -3px; +.nav-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; } -button svg:not(:only-child) { - margin-right: 6px; + +/* Count or state for the view behind the row: how many versions are installed, whether + the game is live. Reading it should not need opening the tab. */ +.nav-badge { + font-size: 10.5px; + font-weight: 600; + min-width: 19px; + height: 18px; + padding: 0 6px; + border-radius: var(--radius-pill); + background: var(--surface-3); + color: var(--text-faint); + display: grid; + place-items: center; + flex: 0 0 auto; } -.nav-item:hover { - background: var(--bg-input); - color: var(--text); +.nav-item.active .nav-badge { + background: rgba(139, 123, 255, 0.22); + color: var(--accent-bright); } -.nav-item.active { - background: var(--bg-input); - color: var(--text); - box-shadow: inset 2px 0 0 var(--accent); +.nav-badge.live { + background: var(--ok-soft); + color: var(--ok); +} +.nav-badge.attention { + background: var(--accent); + color: #fff; } -/* Pushes quick launch to the bottom of the sidebar whatever the nav length. */ .sidebar-spacer { flex: 1; min-height: 12px; } +/* ---------------------------------------------------------------- quick launch */ + .quick-launch { - border-top: 1px solid var(--border); - padding-top: 10px; + border-top: 1px solid var(--border-soft); + padding-top: 12px; display: flex; flex-direction: column; + gap: 7px; +} + +.quick-launch-label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.7px; + text-transform: uppercase; + color: var(--text-faint); + display: flex; + align-items: center; gap: 6px; } .quick-launch select { font-size: 13px; - padding: 5px 6px; + padding: 6px 7px; } .quick-launch button { @@ -213,7 +456,14 @@ button svg:not(:only-child) { .quick-launch-note { font-size: 11px; color: var(--text-faint); - line-height: 1.35; + line-height: 1.4; + display: flex; + gap: 5px; + align-items: flex-start; +} +.quick-launch-note svg { + flex: 0 0 auto; + margin-top: 1px; } .quick-launch-note.error { color: var(--error); @@ -222,7 +472,7 @@ button svg:not(:only-child) { .quick-launch-error { font-size: 11px; color: var(--error); - line-height: 1.35; + line-height: 1.4; /* An error here can be a long path or stack line; keep it from stretching the sidebar. */ max-height: 60px; overflow-y: auto; @@ -232,59 +482,56 @@ button svg:not(:only-child) { .quick-launch-empty { font-size: 11px; color: var(--text-faint); - line-height: 1.4; + line-height: 1.45; } +/* ---------------------------------------------------------------- content */ + .content { overflow-y: auto; - padding: 22px 26px; + padding: 26px 30px 34px; } -.statusbar { - grid-column: 2; - border-top: 1px solid var(--border); - background: var(--bg-raised); - padding: 6px 14px; - display: flex; - align-items: center; - gap: 14px; - font-size: 12px; - color: var(--text-dim); +/* Every view arrives the same way, so switching tabs reads as movement rather than as a + repaint. */ +.view { + max-width: 1100px; + animation: view-in var(--slow) var(--ease); } -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - margin-right: 6px; - background: var(--text-faint); +@keyframes view-in { + from { + opacity: 0; + transform: translateY(6px); + } } -.dot.connected { - background: var(--ok); + +.page-head { + margin-bottom: 22px; } -.dot.running { - background: var(--warn); +.page-head .row { + align-items: flex-start; } -/* ---------------------------------------------------------------- pieces */ - h1 { - font-size: 20px; - margin: 0 0 4px; - font-weight: 600; + font-size: 23px; + line-height: 1.25; + margin: 0 0 5px; + font-weight: 620; + letter-spacing: -0.3px; } h2 { - font-size: 14px; - margin: 22px 0 10px; - color: var(--text-dim); - font-weight: 600; + font-size: 11.5px; + margin: 26px 0 11px; + color: var(--text-faint); + font-weight: 650; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.9px; } .subtitle { color: var(--text-dim); - margin: 0 0 18px; + margin: 0; + max-width: 68ch; } .row { @@ -296,12 +543,35 @@ h2 { flex: 1; } +/* ---------------------------------------------------------------- surfaces */ + .card { - background: var(--bg-raised); - border: 1px solid var(--border); + background: var(--surface-1); + border: 1px solid var(--border-soft); border-radius: var(--radius); - padding: 12px 14px; - margin-bottom: 8px; + padding: 14px 16px; + margin-bottom: 10px; +} + +.card.interactive { + cursor: pointer; + transition: + border-color var(--base) var(--ease), + background var(--base) var(--ease), + transform var(--base) var(--ease); +} +.card.interactive:hover { + border-color: var(--accent-line); + background: var(--surface-2); + transform: translateY(-1px); +} + +.card .name { + font-weight: 550; +} +.card .meta { + color: var(--text-faint); + font-size: 12px; } .list { @@ -314,17 +584,22 @@ h2 { display: flex; align-items: center; gap: 12px; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 9px 12px; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + padding: 10px 13px; cursor: pointer; + transition: + border-color var(--fast) var(--ease), + background var(--fast) var(--ease); } .list-row:hover { - border-color: var(--accent-dim); + background: var(--surface-2); + border-color: var(--border); } .list-row.selected { border-color: var(--accent); + background: var(--accent-soft); } .list-row .name { font-weight: 500; @@ -336,47 +611,79 @@ h2 { .badge { font-size: 11px; - padding: 2px 7px; - border-radius: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-pill); border: 1px solid var(--border); + background: var(--surface-2); color: var(--text-dim); + white-space: nowrap; + flex: 0 0 auto; } .badge.ok { color: var(--ok); - border-color: var(--ok); + border-color: transparent; + background: var(--ok-soft); } .badge.warn { color: var(--warn); - border-color: var(--warn); + border-color: transparent; + background: var(--warn-soft); } .badge.error { color: var(--error); - border-color: var(--error); + border-color: transparent; + background: var(--error-soft); +} +.badge.accent { + color: var(--accent-bright); + border-color: transparent; + background: var(--accent-soft); } +.divider { + height: 1px; + background: var(--border-soft); + margin: 18px 0; +} + +/* ---------------------------------------------------------------- fields */ + .field { - margin-bottom: 12px; + margin-bottom: 14px; } .field label { display: block; - margin-bottom: 4px; + margin-bottom: 5px; color: var(--text-dim); font-size: 12px; + font-weight: 500; } -.field .help { +.field .help, +.help { color: var(--text-faint); - font-size: 11px; - margin-top: 3px; - line-height: 1.4; + font-size: 11.5px; + margin-top: 4px; + line-height: 1.45; } .field.disabled { opacity: 0.45; } +/* Explanatory line under a section heading. Sized in pixels rather than `ch` because at + this font size a `ch` budget produces a column far narrower than it reads as. */ +.section-note { + color: var(--text-faint); + font-size: 12.5px; + line-height: 1.55; + max-width: 640px; + margin: -4px 0 12px; +} + .field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0 18px; + gap: 0 20px; } .inline { @@ -385,114 +692,581 @@ h2 { gap: 8px; } +/* ---------------------------------------------------------------- messages */ + .warning { + display: flex; + gap: 10px; + align-items: flex-start; + border: 1px solid var(--warn-soft); border-left: 3px solid var(--warn); - background: rgba(224, 160, 44, 0.08); - padding: 9px 12px; - border-radius: 0 var(--radius) var(--radius) 0; - margin-bottom: 8px; + background: var(--warn-soft); + padding: 10px 13px; + border-radius: var(--radius-sm); + margin-bottom: 10px; font-size: 13px; line-height: 1.5; } +.warning svg { + flex: 0 0 auto; + margin-top: 2px; + color: var(--warn); +} .warning.error { - border-color: var(--error); - background: rgba(224, 82, 82, 0.08); + border-color: var(--error-soft); + border-left-color: var(--error); + background: var(--error-soft); } -.empty { +/* Pushed to the right edge so the text keeps the full width, and quiet until reached for: + the message is what matters, the way out is what you look for second. */ +.warning-close { + margin-left: auto; + flex: 0 0 auto; + background: none; + border: none; + padding: 1px; color: var(--text-faint); - text-align: center; - padding: 40px 0; } - -.split { - display: grid; - grid-template-columns: 280px 1fr; - gap: 20px; - align-items: start; -} - -.console { - background: #0f0f14; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px; - font-family: 'Cascadia Mono', Consolas, monospace; - font-size: 11px; - line-height: 1.5; - height: 220px; - overflow-y: auto; - user-select: text; - white-space: pre-wrap; - word-break: break-all; +.warning-close:hover { + background: none; + color: var(--text); } -.console .warn { - color: var(--warn); +/* Outranks the tone rules below, which colour every icon inside a banner. */ +.warning button.warning-close svg { + margin-top: 0; + color: inherit; } -.console .error { +.warning.error svg { color: var(--error); } - -.progress { - height: 4px; - background: var(--bg-input); - border-radius: 2px; - overflow: hidden; +.warning.info { + border-color: var(--accent-soft); + border-left-color: var(--accent); + background: var(--accent-soft); } -.progress > div { - height: 100%; - background: var(--accent); - transition: width 0.15s; +.warning.info svg { + color: var(--accent-bright); } -.entry-identity { +/* Empty is a place to act from, not a dead end: an icon to orient, a line saying what is + missing, and the button that fixes it. */ +.empty-state { display: flex; - gap: 16px; - align-items: flex-start; - margin-bottom: 4px; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + padding: 46px 24px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.012); } -.entry-identity-preview { - flex: 0 0 auto; - width: 132px; - height: 132px; - border: 1px solid var(--border); +.empty-state-icon { + width: 46px; + height: 46px; border-radius: var(--radius); - background: var(--bg-raised); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; + display: grid; + place-items: center; + background: var(--surface-2); + color: var(--text-faint); + margin-bottom: 10px; } -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 10px; +.empty-state-title { + font-weight: 550; + font-size: 15px; } -.entry-card { - display: block; - text-align: center; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px 8px; +.empty-state-text { + color: var(--text-faint); + font-size: 13px; + max-width: 46ch; + line-height: 1.5; } -.entry-card:hover { - border-color: var(--accent); + +.empty-state button { + margin-top: 14px; } -.entry-card-preview { +/* Kept for the few places that want a bare line rather than the full panel. */ +.empty { + color: var(--text-faint); + text-align: center; + padding: 40px 0; +} + +/* ---------------------------------------------------------------- guide */ + +/* + * The setup guide: an ordered list of what has to be true before live editing works, each + * step reading its own state off the session rather than being ticked by hand. Steps are + * never hidden once done — seeing the whole path is what makes the order obvious. + */ +.guide { + display: flex; + flex-direction: column; + /* Narrower than the page: the action button belongs beside the step it acts on, not + stranded against the far edge of a wide window. */ + max-width: 780px; +} + +.guide-step { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 14px; + align-items: start; + padding: 13px 4px; + position: relative; +} +/* The rail joins the step markers, so the list reads as one path rather than five cards. */ +.guide-step:not(:last-child)::before { + content: ''; + position: absolute; + left: 14px; + top: 40px; + bottom: -6px; + width: 2px; + background: var(--border); + border-radius: var(--radius-pill); +} +.guide-step.done:not(:last-child)::before { + background: rgba(69, 201, 138, 0.35); +} + +.guide-marker { + width: 30px; + height: 30px; + border-radius: var(--radius-pill); + display: grid; + place-items: center; + font-size: 12.5px; + font-weight: 650; + border: 1.5px solid var(--border); + background: var(--surface-2); + color: var(--text-faint); + z-index: 1; + transition: + background var(--base) var(--ease), + border-color var(--base) var(--ease), + color var(--base) var(--ease); +} +.guide-step.done .guide-marker { + background: var(--ok-soft); + border-color: transparent; + color: var(--ok); +} +.guide-step.current .guide-marker { + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + border-color: transparent; + color: #fff; + box-shadow: var(--glow); +} + +.guide-title { + font-weight: 550; + padding-top: 4px; +} +.guide-step.done .guide-title { + color: var(--text-dim); +} + +.guide-why { + color: var(--text-faint); + font-size: 12.5px; + margin-top: 2px; + line-height: 1.5; + max-width: 58ch; +} + +.guide-step .guide-action { + margin-top: 2px; +} +/* Only the step you are on gets the accent; the rest stay quiet so there is exactly one + obvious thing to click. */ +.guide-step.todo .guide-action { + opacity: 0.55; +} + +/* ---------------------------------------------------------------- home */ + +.hero { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + background: + radial-gradient(620px 220px at 12% 0%, rgba(139, 123, 255, 0.16), transparent 70%), + var(--surface-1); + padding: 24px 26px; + margin-bottom: 20px; +} + +.hero h1 { + font-size: 26px; +} + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; + margin-top: 20px; +} + +.stat { + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 11px 13px; +} + +.stat-value { + font-size: 19px; + font-weight: 620; + letter-spacing: -0.3px; + line-height: 1.2; + display: flex; + align-items: center; + gap: 7px; +} +.stat-value.ok { + color: var(--ok); +} +.stat-value.dim { + color: var(--text-faint); +} + +.stat-label { + font-size: 11px; + color: var(--text-faint); + margin-top: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 550; +} + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px; +} + +.action-card { + display: flex; + gap: 12px; + align-items: flex-start; + text-align: left; + justify-content: flex-start; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 14px; + white-space: normal; +} +.action-card:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--accent-line); + transform: translateY(-1px); +} + +.action-card-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + background: var(--accent-soft); + color: var(--accent-bright); + flex: 0 0 auto; +} + +/* A project name is user-supplied and can be any length; it must not stretch the grid. */ +.action-card-title { + display: block; + font-weight: 550; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-card-text { + font-size: 12px; + color: var(--text-faint); + font-weight: 400; + line-height: 1.45; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------------------------------------------------------------- status bar */ + +.statusbar { + grid-column: 2; + border-top: 1px solid var(--border-soft); + background: var(--surface-1); + padding: 7px 16px; + display: flex; + align-items: center; + gap: 16px; + font-size: 12px; + color: var(--text-dim); +} + +.statusbar-item { + display: flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; + background: var(--text-faint); + flex: 0 0 auto; +} +.dot.connected { + background: var(--ok); + /* Live, and the only thing on screen that moves on its own. */ + animation: pulse 2.4s var(--ease) infinite; +} +.dot.running { + background: var(--warn); + animation: pulse 1.1s var(--ease) infinite; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 currentColor; + opacity: 1; + } + 50% { + box-shadow: 0 0 0 4px transparent; + opacity: 0.55; + } +} + +.progress { + height: 4px; + background: var(--surface-3); + border-radius: var(--radius-pill); + overflow: hidden; +} +.progress > div { + height: 100%; + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--accent), var(--accent-bright)); + transition: width var(--base) var(--ease); +} + +/* ---------------------------------------------------------------- toasts */ + +/* + * Confirmations and failures both land here. Inline banners were being missed: they render + * wherever the action happened, which is often scrolled out of view by the time it + * finishes. + */ +.toast-stack { + position: fixed; + right: 18px; + bottom: 52px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + pointer-events: none; +} + +.toast { + display: flex; + gap: 11px; + align-items: flex-start; + min-width: 260px; + max-width: 400px; + padding: 11px 13px; + border-radius: var(--radius); + background: var(--surface-3); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-3); + pointer-events: auto; + animation: toast-in var(--base) var(--ease); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(14px) scale(0.97); + } +} + +.toast.ok { + border-color: rgba(69, 201, 138, 0.4); +} +.toast.error { + border-color: rgba(236, 95, 95, 0.45); +} + +.toast-icon { + flex: 0 0 auto; + margin-top: 1px; +} +.toast.ok .toast-icon { + color: var(--ok); +} +.toast.error .toast-icon { + color: var(--error); +} +.toast.info .toast-icon { + color: var(--accent-bright); +} + +.toast-body { + flex: 1; + font-size: 13px; + line-height: 1.45; + user-select: text; + word-break: break-word; +} + +.toast-close { + background: none; + border: none; + padding: 2px; + color: var(--text-faint); + flex: 0 0 auto; +} +.toast-close:hover { + color: var(--text); + background: none; +} + +/* The one action in a toast, so it is the one thing in it that reads as pressable. */ +.toast-undo { + flex: 0 0 auto; + padding: 3px 10px; + font-size: 12px; + font-weight: 600; + color: var(--accent-bright); + background: var(--accent-soft); + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); +} +.toast-undo:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +/* The remaining time, drawn rather than left to be guessed at. A toast that can be acted + on and simply disappears reads as one that was missed. */ +.toast:has(.toast-undo) { + position: relative; + overflow: hidden; +} + +.toast-life { + position: absolute; + left: 0; + bottom: 0; + height: 2px; + width: 100%; + transform-origin: left; + background: var(--accent-line); + animation: toast-life linear forwards; +} + +@keyframes toast-life { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} + +@media (prefers-reduced-motion: reduce) { + .toast-life { + display: none; + } +} + +/* ---------------------------------------------------------------- entries */ + +.split { + display: grid; + grid-template-columns: 272px 1fr; + gap: 24px; + align-items: start; +} + +.entry-identity { + display: flex; + gap: 18px; + align-items: flex-start; + margin-bottom: 4px; +} + +.entry-identity-preview { + flex: 0 0 auto; + width: 132px; + height: 132px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: + radial-gradient(90px 90px at 50% 38%, rgba(139, 123, 255, 0.1), transparent 70%), + var(--surface-1); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* Turnable previews: the cursor is the only affordance telling you the model can be + dragged, and touch-action keeps a drag from scrolling the panel instead of rotating. */ +.model-preview-turnable { + cursor: grab; + touch-action: none; + outline-offset: -2px; +} + +.model-preview-turnable:active { + cursor: grabbing; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: 10px; +} + +.entry-card { + display: block; + text-align: center; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 10px 10px; + position: relative; + transition: + border-color var(--base) var(--ease), + background var(--base) var(--ease), + transform var(--base) var(--ease); +} +.entry-card:hover { + border-color: var(--accent-line); + background: var(--surface-2); + transform: translateY(-2px); +} + +.entry-card-preview { display: flex; align-items: center; justify-content: center; height: 112px; - margin-bottom: 6px; + margin-bottom: 8px; } .entry-card-name { - font-weight: 500; + font-weight: 550; font-size: 13px; overflow: hidden; text-overflow: ellipsis; @@ -505,14 +1279,31 @@ h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-top: 1px; } +/* Kind marker in the corner of a card: which of the two things this is, without spending + a line of text on it. */ +.entry-card-kind { + position: absolute; + top: 8px; + left: 8px; + color: var(--text-faint); + opacity: 0.6; +} +.entry-card:hover .entry-card-kind { + opacity: 1; + color: var(--accent-bright); +} + +/* ---------------------------------------------------------------- textures */ + .texture-preview { width: 96px; height: 96px; flex: 0 0 auto; border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--radius-sm); /* Checkerboard behind the image so transparent pixels read as transparent rather than as the panel background. */ background-image: @@ -561,26 +1352,56 @@ h2 { display: flex; gap: 12px; align-items: center; - padding: 8px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); + padding: 10px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--surface-1); } .texture-key { font-size: 13px; - color: var(--accent); + font-weight: 550; + color: var(--accent-bright); } -button.link { - background: none; - border: none; - padding: 0; - color: var(--accent); - text-decoration: underline; +/* ---------------------------------------------------------------- console */ + +.console { + background: var(--surface-inset); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px; + font-family: 'Cascadia Mono', 'JetBrains Mono', Consolas, monospace; + font-size: 11.5px; + line-height: 1.6; + height: 220px; + overflow-y: auto; + user-select: text; + white-space: pre-wrap; + word-break: break-all; } -button.link:hover:not(:disabled) { - color: var(--text); +.console .warn { + color: var(--warn); +} +.console .error { + color: var(--error); +} + +.crash-console { + height: 300px; +} + +/* The logs view is the whole page, so the console grows to fill it rather than sitting in + a fixed box. */ +.logs-console { + height: calc(100vh - 268px); + min-height: 240px; +} + +.scroll-list { + max-height: 460px; + overflow-y: auto; + padding-right: 4px; } /* ---------------------------------------------------------------- modal */ @@ -588,31 +1409,47 @@ button.link:hover:not(:disabled) { .modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(6, 6, 9, 0.7); + backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; - padding: 32px; + padding: 34px; z-index: 100; + animation: fade-in var(--base) var(--ease); +} + +@keyframes fade-in { + from { + opacity: 0; + } } .modal { - background: var(--bg-raised); + background: var(--surface-1); border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--radius-lg); width: min(900px, 100%); max-height: 100%; display: flex; flex-direction: column; - box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-3); + animation: modal-in var(--base) var(--ease); +} + +@keyframes modal-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.99); + } } .modal-header { display: flex; align-items: flex-start; gap: 16px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); + padding: 16px 18px; + border-bottom: 1px solid var(--border-soft); } .modal-title { @@ -630,7 +1467,7 @@ button.link:hover:not(:disabled) { } .modal-body { - padding: 14px 16px; + padding: 16px 18px; overflow-y: auto; flex: 1; min-height: 0; @@ -640,16 +1477,63 @@ button.link:hover:not(:disabled) { display: flex; align-items: center; gap: 10px; - padding: 12px 16px; - border-top: 1px solid var(--border); + padding: 13px 18px; + border-top: 1px solid var(--border-soft); +} + +/* The version dialog carries far less than a crash report; a full-width sheet for a + handful of rows would read as an alarm out of proportion to what it is. */ +.modal.narrow { + width: min(640px, 100%); +} + +/* The crash subtitle is a fault; a version change is not, and painting it in the error + colour would make a routine confirmation look like something went wrong. */ +.modal-subtitle.plain { + color: var(--text-dim); +} + +.modal-footer-note { + padding: 0 18px 13px; + color: var(--text-dim); + font-size: 12px; +} + +.version-swap { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--text-dim); + white-space: nowrap; +} + +.version-swap-to { + color: var(--text); + font-weight: 550; +} + +.compat-heading { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + margin: 14px 0 8px; +} + +/* Wraps rather than truncating: the sentence is the whole reason the row is there. */ +.compat-detail { + color: var(--text-dim); + font-size: 12px; + min-width: 0; } .crash-env { display: grid; grid-template-columns: max-content 1fr; - gap: 3px 14px; + gap: 4px 16px; font-size: 12px; - margin-bottom: 14px; + margin-bottom: 16px; user-select: text; } @@ -667,10 +1551,12 @@ button.link:hover:not(:disabled) { word-break: break-all; } +/* ---------------------------------------------------------------- tabs */ + .tabs { display: flex; - gap: 4px; - margin-bottom: 8px; + gap: 3px; + margin-bottom: 10px; } .tab { @@ -678,10 +1564,12 @@ button.link:hover:not(:disabled) { border: none; border-bottom: 2px solid transparent; border-radius: 0; - padding: 5px 10px; + padding: 6px 11px; color: var(--text-dim); + font-weight: 500; } .tab:hover:not(:disabled) { + background: none; color: var(--text); } .tab.active { @@ -689,19 +1577,95 @@ button.link:hover:not(:disabled) { border-bottom-color: var(--accent); } -.crash-console { - height: 300px; +/* Segmented control: for switching how the same data is shown, where tabs would imply + different content. */ +.segmented { + display: inline-flex; + padding: 2px; + gap: 2px; + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); +} +.segmented button { + background: none; + border: none; + padding: 4px 11px; + font-size: 12.5px; + color: var(--text-faint); + border-radius: 5px; +} +.segmented button:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} +.segmented button.active { + background: var(--surface-3); + color: var(--text); + box-shadow: var(--shadow-1); } -/* The logs view is the whole page, so the console grows to fill it rather than - sitting in a fixed box. */ -.logs-console { - height: calc(100vh - 260px); - min-height: 240px; +/* Splits the available width evenly, for a choice that is the panel's own first question + rather than a control tucked beside a heading. */ +.segmented.fill { + display: flex; + width: 100%; +} +.segmented.fill button { + flex: 1; + min-width: 0; } -.scroll-list { - max-height: 460px; - overflow-y: auto; - padding-right: 4px; +/* Shown while a settings change is on its way to disk and to the game. The controls stay + live behind it — this reports progress, it does not gate anything. */ +.save-indicator { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11.5px; + font-weight: 550; + color: var(--accent-bright); + background: var(--accent-soft); + border-radius: var(--radius-pill); + padding: 3px 11px 3px 8px; + animation: fade-in var(--fast) var(--ease); +} + +.spinner { + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid rgba(169, 155, 255, 0.28); + border-top-color: var(--accent-bright); + animation: spin 700ms linear infinite; + flex: 0 0 auto; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Keyboard shortcut hint, e.g. in the navigation tooltips. */ +.kbd { + font-family: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--text-faint); + background: var(--surface-3); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + padding: 1px 5px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/launcher/src/renderer/src/versions.ts b/launcher/src/renderer/src/versions.ts new file mode 100644 index 0000000..4b65ad4 --- /dev/null +++ b/launcher/src/renderer/src/versions.ts @@ -0,0 +1,22 @@ +/** + * Installed versions, for the forms that need to offer a choice of one. + * + * Read from disk rather than the manifest, so it is instant and works offline — and read + * once per mounted form rather than held globally: these are short-lived panels, and a + * version installed while one of them is open is not a case worth complicating them for. + */ + +import { useEffect, useState } from 'react'; +import type { VersionSummaryDto } from '../../shared/ipc.ts'; + +export function useInstalledVersions(): VersionSummaryDto[] { + const [versions, setVersions] = useState([]); + + useEffect(() => { + void window.ella.versions.installed().then((result) => { + if (result.ok) setVersions(result.value); + }); + }, []); + + return versions; +} diff --git a/launcher/src/renderer/src/views/EditorView.tsx b/launcher/src/renderer/src/views/EditorView.tsx index 70f6541..71e90d7 100644 --- a/launcher/src/renderer/src/views/EditorView.tsx +++ b/launcher/src/renderer/src/views/EditorView.tsx @@ -1,31 +1,50 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { ErrorBanner } from '../components/ErrorBanner.tsx'; +import { useToast } from '../components/Toast.tsx'; import { useI18n } from '../i18n.tsx'; import { SettingsForm } from '../components/SettingsForm.tsx'; import { TexturePanel } from '../components/TexturePanel.tsx'; import { QuickNewEntry } from '../components/QuickNewEntry.tsx'; import { EntryHeader } from '../components/EntryHeader.tsx'; import { usePreviews } from '../previews.ts'; +import { findParentTrap } from '../../../shared/model-compat.ts'; +import type { Facts } from '../facts.ts'; import type { SessionHook } from '../session.ts'; +import type { View } from '../navigation.ts'; interface Props { session: SessionHook; + facts: Facts; selectedId: string | null; /** Null clears the selection, letting the view fall back to the first entry. */ onSelect: (id: string | null) => void; + onNavigate: (view: View) => void; } -export function EditorView({ session, selectedId, onSelect }: Props) { +export function EditorView({ session, facts, selectedId, onSelect, onNavigate }: Props) { const { t, locale } = useI18n(); + const toast = useToast(); const { project, state } = session; const [error, setError] = useState(null); const [ignored, setIgnored] = useState([]); const [deleting, setDeleting] = useState(false); + const [adding, setAdding] = useState(false); + const [fixingParent, setFixingParent] = useState(false); const previews = usePreviews(Boolean(project)); // Any change of selection cancels a pending delete, so a confirmation can never end up // aimed at an entry other than the one it was opened for. - useEffect(() => setDeleting(false), [selectedId]); + // + // The error goes with it, for the same reason: it named a failure on the entry being + // left, and reading it above a different one is worse than not seeing it at all. The + // capability list too — it describes what the last patched entry's settings did. + useEffect(() => { + setDeleting(false); + setError(null); + setIgnored([]); + }, [selectedId]); const entry = useMemo( () => project?.entries.find((candidate) => candidate.id === selectedId) ?? null, @@ -48,13 +67,30 @@ export function EditorView({ session, selectedId, onSelect }: Props) { // Only the absence of a project is a dead end. An empty project still renders the // sidebar, because that is where the button to fill it lives. - if (!project) return
{t('project.noProject')}
; + if (!project) { + return ( +
+ onNavigate('project'), + }} + /> +
+ ); + } const connected = state.status === 'connected'; - const patch = async (settings: Record): Promise => { - if (!entry) return; - const result = await window.ella.entries.patchLive(entry.id, settings); + // Takes the id from the form rather than from the current selection: a patch can still + // be in flight when the user moves to another entry, and it must land on the one it was + // made for. + const patch = async (id: string, settings: Record): Promise => { + const result = await window.ella.entries.patchLive(id, settings); if (!result.ok) { setError(result.message); return; @@ -63,46 +99,129 @@ export function EditorView({ session, selectedId, onSelect }: Props) { setIgnored(result.value?.ignored ?? []); }; - const act = async (action: () => Promise<{ ok: boolean; message?: string }>): Promise => { + // Read off the preview, which already carries the parsed model and is refreshed on every + // Blockbench save — so the warning appears and clears as the file changes. + const parentTrap = findParentTrap( + previews.find((candidate) => candidate.id === entry?.id)?.model, + ); + + const fixParent = async (id: string): Promise => { + setFixingParent(true); + const result = await window.ella.entries.removeModelParent(id); + setFixingParent(false); + + // Success announces itself as an undoable change; only the failure needs saying here. + if (!result.ok) toast.error(result.message); + }; + + const act = async ( + action: () => Promise<{ ok: boolean; message?: string }>, + success?: string, + ): Promise => { const result = await action(); - setError(result.ok ? null : (result.message ?? null)); + if (result.ok) { + setError(null); + if (success) toast.ok(success); + } else { + toast.error(result.message ?? t('common.error')); + } }; + /* + * Why each action is unavailable, said on the control itself. + * + * These three buttons are dark most of the time — before a launch, before the mod + * connects, on a version whose adapter cannot place blocks — and each has a different + * cause with a different fix. Without the reason they read as broken. + */ + const blockbenchBlocked = facts.blockbenchFound ? null : t('blockbench.notFoundHelp'); + const gameBlocked = !connected + ? t('entry.needsGame') + : entry && entry.slot === null + ? t('entry.needsSlot') + : null; + const placeBlocked = + gameBlocked ?? + (session.hasCapability('entry.place') + ? null + : t('capability.unavailable', { version: state.game?.minecraftVersion ?? '?' })); + return ( -
+
-

{t('project.entries')}

- + {/* Heading and its one action on the same line, as in the project view. The list + then starts directly underneath instead of behind a pair of buttons the eye + has to sort out from the entries. */} +
+

{t('project.entries')}

+ + {!adding && ( + + )} +
- {project.entries.length === 0 && ( -
{t('project.noEntries')}
+ {adding && ( + { + setAdding(false); + onSelect(id); + }} + onCancel={() => setAdding(false)} + onError={setError} + /> )} -
- {project.entries.map((candidate) => ( -
onSelect(candidate.id)} - > - {t(`entry.kind.${candidate.kind}`)} - - {candidate.displayName[locale] ?? candidate.displayName.en} - -
- ))} -
+ {project.entries.length === 0 ? ( +
+ {t('project.noEntriesHelp')} +
+ ) : ( +
+ {project.entries.map((candidate) => ( +
onSelect(candidate.id)} + > + + + {candidate.displayName[locale] ?? candidate.displayName.en} + +
+ ))} +
+ )}
{entry && (
-

{entry.displayName[locale] ?? entry.displayName.en}

-

- {entry.id} ·{' '} - {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} -

+
+

{entry.displayName[locale] ?? entry.displayName.en}

+

+ {project.namespace}:{entry.id} ·{' '} + {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} +

+
- {error &&
{error}
} + setError(null)} /> + + {/* Blockbench missing blocks the only action on this page that matters, so it + gets a banner with the fix attached rather than a tooltip on a dark button. */} + {blockbenchBlocked && ( +
+ +
+ {t('blockbench.notFound')} —{' '} + +
+
+ )} + {/* A parent silently overrides the model's own geometry on 1.8.x, so the author + sees a plain cube and reasonably concludes Ella lost their work. Shown + whatever version is connected: the file is wrong for 1.8 either way, and + finding out at launch is the failure worth avoiding. */} + {parentTrap && ( +
+ +
+ {t('model.parentTrap', { + parent: parentTrap.parent, + count: parentTrap.elementCount, + })}{' '} + +
+
+ )} + {ignored.length > 0 && (
- {t('capability.unavailable', { - version: state.game?.minecraftVersion ?? '?', - })} - {`: ${ignored.join(', ')}`} + +
+ {t('capability.unavailable', { + version: state.game?.minecraftVersion ?? '?', + })} + {`: ${ignored.join(', ')}`} +
)} {/* Actions are grouped by where they act: the first three reach outside Ella (Blockbench, the running game), the last changes the project itself. */} -
+
-
@@ -155,6 +316,7 @@ export function EditorView({ session, selectedId, onSelect }: Props) { onDone={() => { setDeleting(false); setError(null); + // No toast here: the deletion announces itself, with the way back attached. // The selection now points at something gone; hand it back so the // auto-select effect picks the next entry. onSelect(null); @@ -167,11 +329,12 @@ export function EditorView({ session, selectedId, onSelect }: Props) { void patch(next)} + onChange={patch} />
)} @@ -224,7 +387,8 @@ function DeleteEntry({ entryId, onCancel, onDone, onError }: DeleteEntryProps) { {deleteFiles && (
- {t('entry.deleteFilesWarning')} + +
{t('entry.deleteFilesWarning')}
)} @@ -239,4 +403,3 @@ function DeleteEntry({ entryId, onCancel, onDone, onError }: DeleteEntryProps) {
); } - diff --git a/launcher/src/renderer/src/views/ExportView.tsx b/launcher/src/renderer/src/views/ExportView.tsx index 8aea563..2736217 100644 --- a/launcher/src/renderer/src/views/ExportView.tsx +++ b/launcher/src/renderer/src/views/ExportView.tsx @@ -1,30 +1,38 @@ import { useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { useToast } from '../components/Toast.tsx'; import type { SessionHook } from '../session.ts'; import type { ExportIssueDto, ExportResultDto } from '../../../shared/ipc.ts'; export function ExportView({ session }: { session: SessionHook }) { const { t } = useI18n(); + const toast = useToast(); const { project } = session; const [issues, setIssues] = useState(null); const [result, setResult] = useState(null); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - if (!project) return
{t('project.noEntries')}
; + if (!project) { + return ( +
+ +
+ ); + } const blocks = project.entries.filter((entry) => entry.kind === 'block'); const items = project.entries.filter((entry) => entry.kind === 'item'); const run = async (): Promise => { setBusy(true); - setError(null); setResult(null); // Validate first: an export that loads but renders wrongly is worse than a refusal. const validation = await window.ella.exporter.validate(); if (!validation.ok) { - setError(validation.message); + toast.error(validation.message); setBusy(false); return; } @@ -32,6 +40,7 @@ export function ExportView({ session }: { session: SessionHook }) { if (validation.value.some((issue) => issue.severity === 'error')) { setBusy(false); + toast.error(t('export.blocked')); return; } @@ -43,49 +52,81 @@ export function ExportView({ session }: { session: SessionHook }) { } const exported = await window.ella.exporter.run(destination); - if (exported.ok) setResult(exported.value); - else setError(exported.message); setBusy(false); + + if (exported.ok) { + setResult(exported.value); + toast.ok(t('export.doneShort')); + } else { + toast.error(exported.message); + } }; return ( -
-

{t('export.title')}

+
+
+

{t('export.title')}

+

{t('export.subtitle')}

+
- {error &&
{error}
} {result && ( -
- {t('export.done', { path: result.path })} · {result.fileCount} ·{' '} - {(result.bytes / 1024).toFixed(1)} KiB +
+ +
+ {t('export.done', { path: result.path })} · {result.fileCount}{' '} + {t('export.files')} · {(result.bytes / 1024).toFixed(1)} KiB +
)} {issues?.map((issue, index) => ( -
- {issue.entryId} — {t(issue.messageKey)} +
+ +
+ {issue.entryId} — {t(issue.messageKey)} +
))}
-
{t('export.resourcePack')}
-
{t('export.resourcePackHelp')}
-
+
+
+
{t('export.resourcePack')}
+
{t('export.resourcePackHelp')}
+
+ {blocks.length} {t('entry.kind.block')} {items.length} {t('entry.kind.item')} - -
+ {/* Announced rather than hidden: knowing a mod export is planned is the difference + between waiting for it and building the registration by hand. */}
-
{t('export.mod')}
-
{t('export.modHelp')}
+
+
+
{t('export.mod')}
+
{t('export.modHelp')}
+
+ + {t('export.planned')} +
); diff --git a/launcher/src/renderer/src/views/HomeView.tsx b/launcher/src/renderer/src/views/HomeView.tsx new file mode 100644 index 0000000..66cea9a --- /dev/null +++ b/launcher/src/renderer/src/views/HomeView.tsx @@ -0,0 +1,363 @@ +/** + * The view Ella opens on. + * + * It answers one question — what should I do next — and answers it differently depending + * on how far along you are. Before the setup is complete it is a guide: five steps read + * off live state, with the next one accented and its button wired to the thing it names. + * After that the checklist folds away and the same space becomes a dashboard of what is + * running, because a finished checklist is not worth a screen. + */ + +import { useEffect, useState } from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon, type IconName } from '../components/Icon.tsx'; +import { useToast } from '../components/Toast.tsx'; +import { ModelPreview } from '../components/ModelPreview.tsx'; +import { usePreviews } from '../previews.ts'; +import type { ProjectSummaryDto } from '../../../shared/ipc.ts'; +import { + workflowSteps, + isSetupComplete, + completedCount, + type WorkflowStep, + type WorkflowStepId, +} from '../../../shared/workflow.ts'; +import type { Facts } from '../facts.ts'; +import type { SessionHook } from '../session.ts'; +import type { View } from '../navigation.ts'; + +interface Props { + session: SessionHook; + facts: Facts; + onNavigate: (view: View) => void; + onOpenEntry: (id: string) => void; +} + +/** Each step's button either moves you to where it happens, or does the thing outright. */ +const STEP_ICON: Record = { + version: 'download', + project: 'folder', + entry: 'plus', + blockbench: 'brush', + launch: 'play', +}; + +export function HomeView({ session, facts, onNavigate, onOpenEntry }: Props) { + const { t, locale } = useI18n(); + const toast = useToast(); + const [showGuide, setShowGuide] = useState(false); + const [launching, setLaunching] = useState(false); + + const steps = workflowSteps(facts); + const complete = isSetupComplete(steps); + const { project, state } = session; + const previews = usePreviews(Boolean(project)); + const [projects, setProjects] = useState([]); + + // Only needed while nothing is open — that is the one moment the answer to "what now" + // is most likely to be "carry on with the one I had yesterday". + useEffect(() => { + if (project) return; + void window.ella.projects.list().then(setProjects); + }, [project]); + + const open = async (root: string): Promise => { + const result = await window.ella.projects.open(root); + if (!result.ok) toast.error(result.message); + }; + + const launch = async (): Promise => { + if (!facts.preferredVersion) return; + setLaunching(true); + const result = await session.requestLaunch(facts.preferredVersion); + setLaunching(false); + if (!result.ok) toast.error(result.message); + }; + + const act = (step: WorkflowStepId): void => { + if (step === 'launch') void launch(); + else if (step === 'version') onNavigate('versions'); + else if (step === 'blockbench') onNavigate('settings'); + else onNavigate('project'); + }; + + const running = state.status === 'running' || state.status === 'starting'; + + return ( +
+
+

{project ? project.name : t('home.welcome')}

+

+ {project ? t('home.projectSubtitle', { namespace: project.namespace }) : t('home.welcomeText')} +

+ +
+ + 0 ? 'normal' : 'dim'} + /> + 0 ? 'normal' : 'dim'} + /> + +
+
+ + {/* One warning that outranks the checklist: versions are installed but none of them + can live-edit, so following every step still ends in a game that never updates. */} + {facts.installedVersions > 0 && facts.liveEditingVersions === 0 && ( +
+ +
+ {t('home.noLiveVersion')}{' '} + +
+
+ )} + + {/* + With nothing open, the fastest route back to work is almost never "create a + project" — it is the one from yesterday. The list goes above the checklist for + that reason, and stays a plain overview: a name, its namespace and how much is in + it, which is all that is knowable without opening the project. + */} + {!project && projects.length > 0 && ( + <> +

{t('home.resume')}

+

{t('home.resumeHelp')}

+ +
+ {projects.map((summary) => ( + + ))} + + +
+ + )} + + {complete ? ( + <> +
+

{t('home.quickActions')}

+ + +
+ +
+ onNavigate('editor')} + /> + onNavigate('project')} + /> + onNavigate('export')} + /> + onNavigate('logs')} + /> +
+ + {showGuide && ( +
+ +
+ )} + + {project && project.entries.length > 0 && ( + <> +

{t('home.recent')}

+
+ {project.entries.slice(0, 6).map((entry) => ( + + ))} +
+ + )} + + ) : ( + <> +
+

{t('guide.title')}

+ + {t('guide.progress', { done: completedCount(steps), total: steps.length })} + +
+

+ {t('guide.subtitle')} +

+ + + )} +
+ ); +} + +// --------------------------------------------------------------------------- + +interface GuideProps { + steps: WorkflowStep[]; + launching: boolean; + onAct: (step: WorkflowStepId) => void; +} + +function Guide({ steps, launching, onAct }: GuideProps) { + const { t } = useI18n(); + + return ( +
+ {steps.map((step, index) => ( +
+
+ {step.done ? : index + 1} +
+ +
+
{t(`guide.${step.id}.title`)}
+ {/* Why, not how. The button already covers how, and a step whose point is + unclear gets skipped or undone later. */} + {!step.done &&
{t(`guide.${step.id}.why`)}
} +
+ + {!step.done && ( + + )} +
+ ))} +
+ ); +} + +interface StatProps { + label: string; + value: string; + tone: 'normal' | 'ok' | 'dim'; + dot?: 'connected' | 'running' | null; +} + +function Stat({ label, value, tone, dot }: StatProps) { + return ( +
+
+ {dot !== undefined && dot !== null && } + {value} +
+
{label}
+
+ ); +} + +interface ActionCardProps { + icon: IconName; + title: string; + text: string; + onClick: () => void; +} + +function ActionCard({ icon, title, text, onClick }: ActionCardProps) { + return ( + + ); +} diff --git a/launcher/src/renderer/src/views/LogsView.tsx b/launcher/src/renderer/src/views/LogsView.tsx index d50f7cc..4a3c33b 100644 --- a/launcher/src/renderer/src/views/LogsView.tsx +++ b/launcher/src/renderer/src/views/LogsView.tsx @@ -46,12 +46,22 @@ export function LogsView({ session }: { session: SessionHook }) { return tally; }, [session.lines]); + const connected = session.state.status === 'connected'; + const running = session.state.status !== 'stopped'; + return ( -
-

{t('nav.logs')}

-

- {session.state.status === 'stopped' ? t('game.disconnected') : t('game.connected')} -

+
+
+

{t('nav.logs')}

+

+ + {connected + ? t('game.connected') + : running + ? t('versions.launching') + : t('game.disconnected')} +

+
([]); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); const [deletingProject, setDeletingProject] = useState(false); const [editing, setEditing] = useState(false); + const [addingEntry, setAddingEntry] = useState(false); // Cards by default: seeing the models is the point of this list. const [view, setView] = useState<'cards' | 'rows'>('cards'); const previews = usePreviews(Boolean(project)); @@ -40,140 +50,166 @@ export function ProjectView({ session, onOpenEntry }: Props) { unwrapOr(await window.ella.projects.close(), (message) => setError(message)); }; - return ( -
-

{t('project.title')}

- {error &&
{error}
} - - {!project || creating ? ( + if (!project || creating) { + return ( +
+
+

{t('project.new')}

+

{t('project.newSubtitle')}

+
+ setError(null)} /> setCreating(false) : undefined} - onCreated={() => { + onCreated={(name) => { setCreating(false); + toast.ok(t('project.createdDone', { name })); refresh(); }} onOpen={open} /> - ) : ( - <> -
-
-
-
{project.name}
-
- {project.namespace} · {project.entries.length} {t('project.entries').toLowerCase()} -
- {session.state.projectRoot && ( -
- {session.state.projectRoot} -
- )} -
- - - - - -
+
+ ); + } + + return ( +
+
+
+
+

{project.name}

+

+ {project.namespace} · {project.entries.length}{' '} + {t('project.entries').toLowerCase()} ·{' '} + {project.targetVersion ?? t('project.targetVersionNone')} +

+ + + + + +
+ {session.state.projectRoot && ( +
{session.state.projectRoot}
+ )} +
- {editing && ( - setEditing(false)} - onError={setError} - /> - )} - - {deletingProject && session.state.projectRoot && ( - setDeletingProject(false)} - onDone={() => { - setDeletingProject(false); - refresh(); - }} - onError={setError} - /> - )} + setError(null)} /> - + {editing && ( + { + setEditing(false); + toast.ok(t('project.savedDone')); + }} + onCancel={() => setEditing(false)} + onError={setError} + /> + )} -
-

{t('project.entries')}

- -
- - -
+ {deletingProject && session.state.projectRoot && ( + setDeletingProject(false)} + onDone={() => { + setDeletingProject(false); + toast.ok(t('project.deletedDone', { name: project.name })); + refresh(); + }} + onError={setError} + /> + )} + +
+

{t('project.entries')}

+ + {project.entries.length > 0 && ( +
+ +
+ )} + +
- {project.entries.length === 0 ? ( -
{t('project.noEntries')}
- ) : view === 'rows' ? ( -
- {project.entries.map((entry) => ( -
onOpenEntry(entry.id)} - > - {t(`entry.kind.${entry.kind}`)} - - {entry.displayName[locale] ?? entry.displayName.en} - - {entry.id} - - - {entry.slot === null - ? t('entry.unbound') - : `${t('entry.slot')} ${entry.slot}`} - -
- ))} -
- ) : ( -
- {project.entries.map((entry) => ( - - ))} + {(addingEntry || project.entries.length === 0) && ( + { + setAddingEntry(false); + toast.ok(t('entry.createdDone', { name })); + onOpenEntry(id); + }} + onCancel={project.entries.length > 0 ? () => setAddingEntry(false) : undefined} + onError={setError} + /> + )} + + {project.entries.length === 0 ? ( + !addingEntry && ( + + ) + ) : view === 'rows' ? ( +
+ {project.entries.map((entry) => ( +
onOpenEntry(entry.id)}> + + + {entry.displayName[locale] ?? entry.displayName.en} + + + {project.namespace}:{entry.id} + + + + {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} +
- )} - + ))} +
+ ) : ( +
+ {project.entries.map((entry) => ( + + ))} +
)}
); @@ -184,32 +220,44 @@ export function ProjectView({ session, onOpenEntry }: Props) { interface EditProjectProps { project: EllaProject; onDone: () => void; + onCancel: () => void; onError: (message: string) => void; } /** - * Editing the open project's name and namespace. + * Editing the open project's name, namespace and target version. * * The namespace warning is not decoration: it names the asset directory and appears in * every texture reference inside the models, so changing it rewrites files. Ella handles * that, but anything referencing the old namespace from outside the project — a hand-written * model, an already-exported pack — will not follow. + * + * Changing the target version here deliberately does *not* migrate the models: this is the + * place to correct a binding that was wrong, and the launch dialog is the place to move a + * project across versions, because that is where the consequences can be listed against + * the files as they actually are. */ -function EditProject({ project, onDone, onError }: EditProjectProps) { +function EditProject({ project, onDone, onCancel, onError }: EditProjectProps) { const { t } = useI18n(); const [name, setName] = useState(project.name); const [namespace, setNamespace] = useState(project.namespace); + const [targetVersion, setTargetVersion] = useState(project.targetVersion ?? ''); const [busy, setBusy] = useState(false); + const versions = useInstalledVersions(); const namespaceChanged = namespace.trim() !== project.namespace; const valid = name.trim().length > 0 && isValidIdentifier(namespace.trim()); - const changed = name.trim() !== project.name || namespaceChanged; + const changed = + name.trim() !== project.name || + namespaceChanged || + targetVersion !== (project.targetVersion ?? ''); const save = async (): Promise => { setBusy(true); const result = await window.ella.projects.updateInfo({ name: name.trim(), namespace: namespace.trim(), + targetVersion: targetVersion === '' ? null : targetVersion, }); setBusy(false); @@ -219,33 +267,55 @@ function EditProject({ project, onDone, onError }: EditProjectProps) { return (
-

{t('project.edit')}

- -
- - setName(event.target.value)} /> +
+ {t('project.edit')}
-
- - setNamespace(event.target.value)} /> -
{t('project.namespaceHelp')}
+
+
+ + setName(event.target.value)} /> +
+ +
+ + setNamespace(event.target.value)} /> +
{t('project.namespaceHelp')}
+
+ +
+ + +
{t('project.targetVersionHelp')}
+
{namespaceChanged && (
- {t('project.namespaceChangeWarning', { - from: project.namespace, - to: namespace.trim(), - })} + +
+ {t('project.namespaceChangeWarning', { + from: project.namespace, + to: namespace.trim(), + })} +
)}
- -
@@ -293,11 +363,14 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP
{t('project.deleteTitle', { name })}
-
- {t('project.deleteWarning', { - entries: footprint?.entryCount ?? 0, - files: footprint?.authoredFiles ?? 0, - })} +
+ +
+ {t('project.deleteWarning', { + entries: footprint?.entryCount ?? 0, + files: footprint?.authoredFiles ?? 0, + })} +
@@ -310,6 +383,7 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP className="danger" onClick={() => void confirm()} disabled={busy || typed !== name} + title={typed !== name ? t('project.deleteConfirmLabel', { name }) : undefined} > {t('project.delete')} @@ -321,10 +395,46 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP ); } +/** + * Picks the version a project is authored against. + * + * "None" is a real answer rather than a placeholder: an unbound project takes the version + * of its next launch, which is how every project made before this field existed acquires + * one without being asked. + */ +function VersionSelect({ + value, + versions, + onChange, +}: { + value: string; + versions: VersionSummaryDto[]; + onChange: (value: string) => void; +}) { + const { t } = useI18n(); + const known = versions.some((version) => version.id === value); + + return ( + + ); +} + interface CreateProps { existing: ProjectSummaryDto[]; onCancel?: () => void; - onCreated: () => void; + onCreated: (name: string) => void; onOpen: (root: string) => void; } @@ -333,63 +443,107 @@ function CreateProject({ existing, onCancel, onCreated, onOpen }: CreateProps) { const [name, setName] = useState(''); const [namespace, setNamespace] = useState(''); const [touched, setTouched] = useState(false); + const [targetVersion, setTargetVersion] = useState(''); const [error, setError] = useState(null); + const versions = useInstalledVersions(); // The namespace tracks the name until the user edits it themselves. const effectiveNamespace = touched ? namespace : slugify(name); const valid = name.trim().length > 0 && isValidIdentifier(effectiveNamespace); const create = async (): Promise => { - const result = await window.ella.projects.create(name.trim(), effectiveNamespace, []); - if (unwrapOr(result, (message) => setError(message))) onCreated(); + const result = await window.ella.projects.create( + name.trim(), + effectiveNamespace, + targetVersion === '' ? null : targetVersion, + ); + if (unwrapOr(result, (message) => setError(message))) onCreated(name.trim()); }; return ( <> -
-

{t('project.new')}

- {error &&
{error}
} - -
- - setName(event.target.value)} /> -
- -
- - { - setTouched(true); - setNamespace(event.target.value); - }} - /> -
{t('project.namespaceHelp')}
-
- -
- - {onCancel && } -
-
- + {/* Opening beats creating when something is already there, so the existing list + comes first — a second project made by accident is a split workspace. */} {existing.length > 0 && ( <> -

{t('project.open')}

-
+

{t('project.open')}

+
{existing.map((summary) => ( -
onOpen(summary.root)}> +
onOpen(summary.root)} + > + {summary.name} {summary.namespace} - {summary.entryCount} + {/* The version comes before the entry count: it is what decides whether + opening this project also changes what the launcher will start. */} + {summary.targetVersion && ( + {summary.targetVersion} + )} + + {summary.entryCount} {t('project.entries').toLowerCase()} + +
))}
+

{t('project.new')}

)} + +
+ setError(null)} /> + +
+
+ + setName(event.target.value)} + /> +
+ +
+ + { + setTouched(true); + setNamespace(event.target.value); + }} + /> +
{t('project.namespaceHelp')}
+
+ +
+ + +
{t('project.targetVersionHelp')}
+
+
+ +
+ + {onCancel && } +
+
); } @@ -397,18 +551,26 @@ function CreateProject({ existing, onCancel, onCreated, onOpen }: CreateProps) { // --------------------------------------------------------------------------- interface NewEntryProps { - session: SessionHook; - onCreated: (id: string) => void; + onCreated: (id: string, name: string) => void; + onCancel?: () => void; onError: (message: string) => void; } -function NewEntry({ session, onCreated, onError }: NewEntryProps) { +/** + * Creating a block or an item. + * + * The kind is picked from two labelled buttons rather than a dropdown: it is the one + * choice here that cannot be changed afterwards, and a closed `select` showing "Block" + * does not read as a decision at all. + */ +function NewEntry({ onCreated, onCancel, onError }: NewEntryProps) { const { t } = useI18n(); const [kind, setKind] = useState('block'); const [nameEn, setNameEn] = useState(''); const [nameFr, setNameFr] = useState(''); const [id, setId] = useState(''); const [touched, setTouched] = useState(false); + const [busy, setBusy] = useState(false); const effectiveId = touched ? id : slugify(nameEn); const valid = nameEn.trim().length > 0 && isValidIdentifier(effectiveId); @@ -418,47 +580,84 @@ function NewEntry({ session, onCreated, onError }: NewEntryProps) { ? { en: nameEn.trim(), fr: nameFr.trim() } : { en: nameEn.trim() }; + setBusy(true); const result = await window.ella.entries.create({ id: effectiveId, kind, displayName }); - const entry = result.ok ? result.value : null; - if (!entry) { - onError(result.ok ? '' : result.message); + setBusy(false); + + if (!result.ok) { + onError(result.message); return; } + const created = result.value; setNameEn(''); setNameFr(''); setId(''); setTouched(false); - onCreated(entry.id); + onCreated(created.id, nameEn.trim()); }; - return ( -
-

{t('entry.new')}

+ const submitOnEnter = (event: KeyboardEvent): void => { + if (event.key === 'Enter' && valid && !busy) void create(); + }; -
-
- - + return ( +
+
+ +
+ {(['block', 'item'] as EntryKind[]).map((candidate) => ( + + ))}
+
+
- setNameEn(event.target.value)} /> + setNameEn(event.target.value)} + />
- setNameFr(event.target.value)} /> + setNameFr(event.target.value)} + />
{ setTouched(true); setId(event.target.value); @@ -468,14 +667,18 @@ function NewEntry({ session, onCreated, onError }: NewEntryProps) {
- - {session.state.status === 'connected' && ( - - {t('game.connected')} - - )} +
+ + {onCancel && } +
); } diff --git a/launcher/src/renderer/src/views/SettingsView.tsx b/launcher/src/renderer/src/views/SettingsView.tsx index ccebee1..7745845 100644 --- a/launcher/src/renderer/src/views/SettingsView.tsx +++ b/launcher/src/renderer/src/views/SettingsView.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from '../components/Icon.tsx'; +import { useToast } from '../components/Toast.tsx'; import { LOCALES, LOCALE_NAMES, type Locale } from '../../../shared/i18n.ts'; import type { AppConfigDto, JavaRuntimeDto } from '../../../shared/ipc.ts'; @@ -9,10 +11,10 @@ import type { AppConfigDto, JavaRuntimeDto } from '../../../shared/ipc.ts'; */ function BlockbenchPlugin() { const { t } = useI18n(); + const toast = useToast(); const [status, setStatus] = useState< { installed: boolean; outdated: boolean; installedPath: string } | null >(null); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const refresh = (): void => { @@ -24,45 +26,52 @@ function BlockbenchPlugin() { const install = async (): Promise => { setBusy(true); const result = await window.ella.blockbench.installPlugin(); - if (!result.ok) setError(result.message); - else setError(null); setBusy(false); + if (result.ok) toast.ok(t('plugin.installedDone')); + else toast.error(result.message); refresh(); }; return ( - <> -

{t('plugin.title')}

- {error &&
{error}
} -
-
-
-
{t('plugin.liveSync')}
-
{t('plugin.liveSyncHelp')}
-
- - {status?.installed && !status.outdated && ( - {t('plugin.installed')} - )} - {status?.outdated && {t('plugin.outdated')}} - +
+
+
+
{t('plugin.liveSync')}
+
{t('plugin.liveSyncHelp')}
- {status?.installed &&
{status.installedPath}
} + + {status?.installed && !status.outdated && ( + {t('plugin.installed')} + )} + {status?.outdated && {t('plugin.outdated')}} +
- + {status?.installed &&
{status.installedPath}
} +
); } -export function SettingsView() { +interface Props { + /** A Blockbench path change moves a setup step from undone to done. */ + onChanged: () => void; +} + +export function SettingsView({ onChanged }: Props) { const { t, locale, setLocale } = useI18n(); const [config, setConfig] = useState(null); const [runtimes, setRuntimes] = useState([]); + const [detected, setDetected] = useState(null); + + const refreshDetected = (): void => { + void window.ella.blockbench.resolve().then(setDetected); + }; useEffect(() => { void window.ella.config.get().then(setConfig); void window.ella.java.list().then(setRuntimes); + refreshDetected(); }, []); if (!config) return null; @@ -72,51 +81,88 @@ export function SettingsView() { void window.ella.config.set(patch); }; + const updateBlockbench = (path: string | null): void => { + update({ blockbenchPath: path }); + // Resolution also falls back to the well-known install locations, so the answer to + // "will opening a model work" is the main process's, not this input's. + setTimeout(() => { + refreshDetected(); + onChanged(); + }, 0); + }; + const browseBlockbench = async (): Promise => { const chosen = await window.ella.dialog.openFile([ { name: 'Blockbench', extensions: ['exe', 'app', ''] }, ]); - if (chosen) update({ blockbenchPath: chosen }); + if (chosen) updateBlockbench(chosen); }; return ( -
-

{t('settings.title')}

- -
- - +
+
+

{t('settings.title')}

+

{t('settings.subtitle')}

-
- - update({ username: event.target.value })} - /> +
+
+ + +
+ +
+ + update({ username: event.target.value })} + /> +
{t('settings.usernameHelp')}
+
-
+

{t('settings.blockbench')}

+ +
update({ blockbenchPath: event.target.value || null })} + placeholder={t('settings.blockbenchPlaceholder')} + onChange={(event) => updateBlockbench(event.target.value || null)} /> - +
+ {/* Whether Ella can find Blockbench is not the same question as whether this box + is filled in, and only the first one decides if opening a model works. */} + {detected ? ( +
+ {config.blockbenchPath + ? t('settings.blockbenchOk') + : t('settings.blockbenchDetected', { path: detected })} +
+ ) : ( +
+ {t('settings.blockbenchMissing')} +
+ )}
+ + +

{t('settings.slotPool')}

- +
- +
-
+
{t('settings.slotPoolHelp')}
- -

Java

-
- {runtimes.map((runtime) => ( -
- Java {runtime.major} - {runtime.version} - - {runtime.path} -
- ))} -
+

{t('settings.javaHelp')}

+ {runtimes.length === 0 ? ( +
+ +
{t('settings.javaNone')}
+
+ ) : ( +
+ {runtimes.map((runtime) => ( +
+ Java {runtime.major} + {runtime.version} + + {runtime.path} +
+ ))} +
+ )}
); } diff --git a/launcher/src/renderer/src/views/VersionsView.tsx b/launcher/src/renderer/src/views/VersionsView.tsx index 79b6018..47f4b1a 100644 --- a/launcher/src/renderer/src/views/VersionsView.tsx +++ b/launcher/src/renderer/src/views/VersionsView.tsx @@ -1,11 +1,22 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../i18n.tsx'; import { unwrapOr } from '../result.ts'; +import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { ErrorBanner } from '../components/ErrorBanner.tsx'; +import { useToast } from '../components/Toast.tsx'; import type { SessionHook } from '../session.ts'; import type { VersionSummaryDto, InstallFootprintDto } from '../../../shared/ipc.ts'; -export function VersionsView({ session }: { session: SessionHook }) { +interface Props { + session: SessionHook; + /** Installs and uninstalls change what the setup guide and the sidebar count. */ + onChanged: () => void; +} + +export function VersionsView({ session, onChanged }: Props) { const { t } = useI18n(); + const toast = useToast(); const [versions, setVersions] = useState([]); const [showSnapshots, setShowSnapshots] = useState(false); const [busy, setBusy] = useState(null); @@ -33,6 +44,7 @@ export function VersionsView({ session }: { session: SessionHook }) { const refreshAll = async (): Promise => { await Promise.all([refreshInstalled(), refresh(showSnapshots)]); + onChanged(); }; useEffect(() => { @@ -49,51 +61,62 @@ export function VersionsView({ session }: { session: SessionHook }) { void refresh(next); }; - const install = async (id: string): Promise => { + const install = async (id: string, repair = false): Promise => { setBusy(id); const result = await window.ella.versions.install(id); - unwrapOr(result, (message) => setError(message)); setBusy(null); + + if (result.ok) { + toast.ok(t(repair ? 'versions.repairDone' : 'versions.installDone', { version: id })); + } else { + toast.error(result.message); + } + void refreshAll(); }; const launch = async (id: string): Promise => { setBusy(id); - const result = await window.ella.game.launch(id); - unwrapOr(result, (message) => setError(message)); + // Through the session, so launching from the list is guarded the same way as launching + // from the sidebar — this is the button most likely to be a deliberate version change. + const result = await session.requestLaunch(id); + unwrapOr(result, (message) => toast.error(message)); setBusy(null); }; const gameRunning = session.state.status !== 'stopped'; const installedIds = new Set(installed.map((version) => version.id)); + const available = versions.filter((version) => !installedIds.has(version.id)); + + /* + * Versions with a built adapter get their own section above the full list. + * + * "Which Minecraft version should I pick" is the first decision Ella asks for and the + * one it is worst at leaving to the user: every version in the manifest launches, but + * only a handful can live-edit, and picking wrong is only discovered after a download + * and a launch that quietly never syncs. + */ + const recommended = available.filter((version) => version.adapterStatus === 'built'); + const rest = available.filter((version) => version.adapterStatus !== 'built'); + + const rowProps = (version: VersionSummaryDto) => ({ + version, + busy: busy === version.id, + gameRunning, + onInstall: () => void install(version.id, version.installed), + onLaunch: () => void launch(version.id), + onUninstall: () => setConfirming(version.id), + }); return ( -
-

{t('versions.title')}

-

{t('app.tagline')}

- - {error &&
{error}
} - -
- - - - {gameRunning && ( - - )} +
+
+

{t('versions.title')}

+

{t('versions.subtitle')}

+ setError(null)} /> + {/* Installed versions first and unfiltered: they are what you actually launch, and they stay visible even when the snapshot filter hides them from the list below or the manifest cannot be reached. */} @@ -101,30 +124,26 @@ export function VersionsView({ session }: { session: SessionHook }) { {t('versions.installed')} ({installed.length}) {installed.length === 0 ? ( -
- {t('versions.noneInstalled')} -
+ ) : ( -
+
{installed.map((version) => (
- void install(version.id)} - onLaunch={() => void launch(version.id)} - onUninstall={() => setConfirming(version.id)} - /> + {confirming === version.id && ( { setConfirming(null); + toast.ok(t('versions.uninstallDone', { version: version.id })); void refreshAll(); }} onCancel={() => setConfirming(null)} - onError={setError} + onError={(message) => toast.error(message)} /> )}
@@ -132,25 +151,48 @@ export function VersionsView({ session }: { session: SessionHook }) {
)} -

{t('versions.available')}

+ {recommended.length > 0 && ( + <> +

{t('versions.recommended')}

+

{t('versions.recommendedHelp')}

+
+ {recommended.map((version) => ( + + ))} +
+ + )} + +
+

{t('versions.available')}

+ + + +
+ {loading && versions.length === 0 &&
} {/* Anything installed is shown above, so this list is the not-yet-installed remainder — no uninstall affordance can apply here. */}
- {versions - .filter((version) => !installedIds.has(version.id)) - .map((version) => ( - void install(version.id)} - onLaunch={() => void launch(version.id)} - onUninstall={() => setConfirming(version.id)} - /> - ))} + {rest.map((version) => ( + + ))}
); @@ -159,18 +201,33 @@ export function VersionsView({ session }: { session: SessionHook }) { interface RowProps { version: VersionSummaryDto; busy: boolean; - disabled: boolean; + gameRunning: boolean; onInstall: () => void; onLaunch: () => void; onUninstall: () => void; } -function VersionRow({ version, busy, disabled, onInstall, onLaunch, onUninstall }: RowProps) { +function VersionRow({ version, busy, gameRunning, onInstall, onLaunch, onUninstall }: RowProps) { const { t } = useI18n(); + /* + * Why a button cannot be pressed, in the order the user would hit them. + * + * A disabled button with no tooltip is a dead end — people conclude the app is broken + * rather than that something is missing, and the fix (install a JDK, stop the game) is + * never something they would guess. + */ + const launchBlocked = !version.javaAvailable + ? t('versions.javaMissing', { java: version.requiredJava }) + : gameRunning + ? t('versions.alreadyRunning') + : null; + + const installBlocked = !version.supported ? t('versions.unsupportedReason') : null; + return (
- + {version.id} @@ -183,41 +240,70 @@ function VersionRow({ version, busy, disabled, onInstall, onLaunch, onUninstall {t('versions.adapterNotBuilt')} ) : ( - + {t('versions.vanillaOnly')} )} {version.javaAvailable ? ( - {t('versions.javaRequired', { java: version.requiredJava })} + + {t('versions.javaRequired', { java: version.requiredJava })} + ) : ( {t('versions.javaMissing', { java: version.requiredJava })} )} - {version.installed && {t('versions.installed')}} - {version.releaseTime.slice(0, 10)} {!version.installed && ( - )} + {/* Launch only appears once there is something to launch. A disabled Launch on every + uninstalled row is a column of dead buttons, and Install is already the one + action that applies. */} {version.installed && ( - + <> + {/* Re-runs the whole install over an existing one. Needed because a version can + be installed and still incomplete — a Forge step that failed leaves files + missing, and without this the only way to fetch them was to uninstall and + start over, which also throws away the worlds. */} + + + + )} -
); } @@ -262,7 +348,7 @@ function UninstallConfirm({ versionId, onDone, onCancel, onError }: ConfirmProps }; return ( -
+
{t('versions.uninstallTitle', { version: versionId })}
@@ -285,7 +371,8 @@ function UninstallConfirm({ versionId, onDone, onCancel, onError }: ConfirmProps {removeInstance && (
- {t('versions.uninstallWorldsWarning')} + +
{t('versions.uninstallWorldsWarning')}
)} diff --git a/launcher/src/shared/app.ts b/launcher/src/shared/app.ts new file mode 100644 index 0000000..e964f67 --- /dev/null +++ b/launcher/src/shared/app.ts @@ -0,0 +1,14 @@ +/** + * The launcher's own identity. + * + * Shared rather than main-only because it is needed on both sides: Minecraft is told which + * launcher started it, a crash report has to name the build it came from, and the sidebar + * shows it so a user filing that report does not have to go looking. + * + * Written out rather than read from package.json, because the renderer has no filesystem + * and bundling the manifest to recover one string is not a trade worth making. A test keeps + * the two in step. + */ + +export const APP_NAME = 'Ella'; +export const APP_VERSION = '0.2.0'; diff --git a/launcher/src/shared/ipc.ts b/launcher/src/shared/ipc.ts index fdd02f2..ae6039f 100644 --- a/launcher/src/shared/ipc.ts +++ b/launcher/src/shared/ipc.ts @@ -55,6 +55,36 @@ export interface ProjectSummaryDto { namespace: string; root: string; entryCount: number; + /** The Minecraft version it is authored against, or null when unbound. */ + targetVersion: string | null; +} + +/** One thing in the project's files that a version change would break. */ +export interface CompatFindingDto { + entryId: string; + /** A {@link CompatIssueId}; also the suffix of its `compat.issue.*` message. */ + issue: string; + /** True when Ella can rewrite the file itself. */ + fixable: boolean; + /** Interpolation values for the message. */ + detail: Record; +} + +export interface VersionChangePlanDto { + /** The version the project is bound to, or null when nothing has bound it yet. */ + from: string | null; + to: string; + /** True when this launch is a change worth stopping for. */ + needsConfirmation: boolean; + findings: CompatFindingDto[]; + /** How many findings Ella can rewrite the files for. */ + fixable: number; +} + +export interface VersionChangeResultDto { + project: EllaProject; + /** Entry ids whose model files were rewritten. */ + migrated: string[]; } export interface TextureVariableDto { @@ -153,6 +183,20 @@ export interface ExportResultDto { bytes: number; } +/** + * An action that can still be taken back, pushed as an event the moment it happens. + * + * Sent rather than returned so the offer is independent of whichever call produced it: the + * notification layer subscribes once, and a handler becomes undoable by registering an + * inverse instead of by changing its signature. + */ +export interface UndoOfferDto { + token: string; + /** i18n key describing what happened; the renderer translates it. */ + messageKey: string; + values: Record; +} + export interface SettingsPatchResultDto { applied: string[]; ignored: string[]; @@ -190,7 +234,7 @@ export interface EllaApi { projects: { list(): Promise; - create(name: string, namespace: string, targetVersions: string[]): Promise< + create(name: string, namespace: string, targetVersion: string | null): Promise< Result<{ project: EllaProject; root: string }> >; open(root: string): Promise>; @@ -199,12 +243,24 @@ export interface EllaApi { measure(): Promise>; delete(root: string): Promise>; close(): Promise>; - /** Edits the open project's name, namespace and target versions. */ + /** Edits the open project's name, namespace and target version. */ updateInfo(changes: { name?: string; namespace?: string; - targetVersions?: string[]; + /** Null unbinds it; omitted leaves it alone. */ + targetVersion?: string | null; }): Promise>; + /** + * What launching `versionId` would mean for the open project, without changing + * anything. Reads every entry's model, so it is the answer for the files as they + * actually are rather than as the manifest describes them. + */ + planVersionChange(versionId: string): Promise>; + /** Binds the project to `versionId`, rewriting the models that need it when asked. */ + applyVersionChange( + versionId: string, + migrate: boolean, + ): Promise>; }; entries: { @@ -236,12 +292,22 @@ export interface EllaApi { /** Points the model's `particle` variable at this one, or clears it with null. */ setParticleTexture(id: string, key: string | null): Promise>; revealTexture(id: string): Promise>; + /** + * Strips a `parent` that would override the model's own geometry on 1.8.x. + * Resolves to the removed parent, or null when there was nothing to fix. + */ + removeModelParent(id: string): Promise>; /** Preview data for every entry, batched for the card grid. */ previews(): Promise>; give(id: string): Promise>; place(id: string): Promise>; }; + undo: { + /** Reverses the action a {@link UndoOfferDto} names. One-shot. */ + run(token: string): Promise>; + }; + game: { launch(versionId: string): Promise>; stop(): Promise>; @@ -256,6 +322,13 @@ export interface EllaApi { }; blockbench: { + /** + * The executable Ella would actually launch, or null when it cannot find one. + * + * Not the same as the configured path: Blockbench is usually auto-detected, so an + * empty setting says nothing about whether opening a model will work. + */ + resolve(): Promise; pluginStatus(): Promise<{ installed: boolean; outdated: boolean; installedPath: string }>; installPlugin(): Promise>; }; @@ -284,6 +357,8 @@ export interface EllaApi { crash(handler: (diagnostics: CrashDiagnosticsDto) => void): () => void; /** Fires when watched model or texture files change on disk. */ files(handler: (paths: string[]) => void): () => void; + /** Fires after a change that can still be taken back. */ + undo(handler: (offer: UndoOfferDto) => void): () => void; }; } @@ -305,6 +380,8 @@ export const CHANNELS = { projectsDelete: 'projects:delete', projectsClose: 'projects:close', projectsUpdateInfo: 'projects:updateInfo', + projectsPlanVersionChange: 'projects:planVersionChange', + projectsApplyVersionChange: 'projects:applyVersionChange', entriesCreate: 'entries:create', entriesUpdate: 'entries:update', entriesDelete: 'entries:delete', @@ -317,9 +394,11 @@ export const CHANNELS = { entriesRemoveTexture: 'entries:removeTexture', entriesSetParticle: 'entries:setParticle', entriesRevealTexture: 'entries:revealTexture', + entriesRemoveModelParent: 'entries:removeModelParent', entriesPreviews: 'entries:previews', entriesGive: 'entries:give', entriesPlace: 'entries:place', + undoRun: 'undo:run', gameLaunch: 'game:launch', gameStop: 'game:stop', gameState: 'game:state', @@ -327,6 +406,7 @@ export const CHANNELS = { exportValidate: 'export:validate', exportRun: 'export:run', exportSuggestName: 'export:suggestName', + blockbenchResolve: 'blockbench:resolve', blockbenchPluginStatus: 'blockbench:pluginStatus', blockbenchInstallPlugin: 'blockbench:installPlugin', crashCopy: 'crash:copy', @@ -343,4 +423,5 @@ export const EVENTS = { progress: 'event:progress', crash: 'event:crash', files: 'event:files', + undo: 'event:undo', } as const; diff --git a/launcher/src/shared/model-compat.ts b/launcher/src/shared/model-compat.ts new file mode 100644 index 0000000..0dde560 --- /dev/null +++ b/launcher/src/shared/model-compat.ts @@ -0,0 +1,68 @@ +/** + * Cross-version model compatibility checks. + * + * One divergence matters enough to have its own module, because it is silent, it only + * shows up in game, and it looks like Ella losing the model rather than like a model + * problem. + * + * **A model that declares `parent` has its own `elements` ignored on 1.8.x.** + * + * `ModelBlock.getElements()` on 1.8.9 reads, in full: + * + * ```java + * return this.hasParent() ? this.parent.getElements() : this.elements; + * ``` + * + * with `hasParent()` being nothing more than `parent != null`. The parent wins + * unconditionally. That is why vanilla 1.8.9's own `block/cube.json` declares no parent at + * all and inlines its elements — the file only gained `"parent": "block/block"` in 1.9, + * when the semantics flipped to "the child's elements win if it has any". + * + * So a model carrying both — which is exactly what Blockbench writes when you add geometry + * to a model that started as `block/cube_all` — renders as its parent on 1.8.x. With the + * texture keys Blockbench also rewrites, the parent's `#all` no longer resolves either, so + * the result is a full cube in the missing-texture checkerboard: the model appears not to + * have loaded, when in fact it loaded and was overruled. + */ + +export interface ParentTrap { + /** The parent that will win over the model's own geometry. */ + parent: string; + /** How many elements are being discarded, for a message worth reading. */ + elementCount: number; +} + +/** The versions whose loader lets a parent override the child's geometry. */ +export const PARENT_OVERRIDES_ELEMENTS_BELOW = '1.9'; + +/** + * Reports a model that will render as its parent instead of as itself. + * + * Returns null for the two shapes that are always fine: a model with a parent and no + * geometry of its own (the normal `cube_all` case, and Ella's own slot redirects), and a + * self-contained model with no parent. + */ +export function findParentTrap(model: unknown): ParentTrap | null { + if (typeof model !== 'object' || model === null) return null; + + const candidate = model as { parent?: unknown; elements?: unknown }; + if (typeof candidate.parent !== 'string' || candidate.parent.length === 0) return null; + if (!Array.isArray(candidate.elements) || candidate.elements.length === 0) return null; + + return { parent: candidate.parent, elementCount: candidate.elements.length }; +} + +/** + * Removes the parent, leaving the model self-contained. + * + * Dropping it is the whole fix and it costs nothing: a model with its own elements never + * needed the parent for geometry, and on every version from 1.9 onwards the parent's + * geometry was already being ignored. Texture variables resolve against the model's own + * map, which Blockbench has written by the time there are elements to resolve them for. + * + * Returns a new object; the input is left alone. + */ +export function withoutParent(model: Record): Record { + const { parent: _dropped, ...rest } = model; + return rest; +} diff --git a/launcher/src/shared/model-preview.ts b/launcher/src/shared/model-preview.ts index c9accc3..6c604c3 100644 --- a/launcher/src/shared/model-preview.ts +++ b/launcher/src/shared/model-preview.ts @@ -1,16 +1,18 @@ /** - * Isometric preview of a Minecraft block model. + * Orthographic preview of a Minecraft block model, from any camera angle. * * A block model is a list of axis-aligned boxes with a texture region per face, so an - * exact isometric render needs no 3D engine: each visible face is a parallelogram, and a - * canvas affine transform maps the texture rectangle onto it. + * exact render needs no 3D engine: each face is a parallelogram on screen, and a canvas + * affine transform maps the texture rectangle onto it. * - * Only the three faces an isometric camera can see are drawn — up, south and east — which - * is what makes the result read as a block rather than a flat sprite. + * The camera is described by two angles — `yaw` around the vertical axis and `pitch` for + * its elevation — so the preview can be turned. Only the faces whose outward normal points + * at the camera are drawn, which is what makes the result read as a solid block rather + * than a flat sprite. The default view is the isometric one this preview has always used. * * Known limits, deliberate rather than accidental: * - element rotations are ignored (vanilla allows one 22.5° step per element) - * - no lighting beyond a fixed shade per face direction + * - no lighting beyond vanilla's fixed shade per face direction * - a model with no `elements` of its own falls back to a full cube, which is what * inheriting `block/cube_all` amounts to */ @@ -32,20 +34,107 @@ export interface ParsedModel { textures: Record; } -/** Faces an isometric view can see, drawn back to front. */ -const VISIBLE_FACES = ['up', 'south', 'east'] as const; -export type VisibleFace = (typeof VISIBLE_FACES)[number]; +type Vec3 = [number, number, number]; + +/** The six faces of a box. */ +const FACES = ['up', 'down', 'north', 'south', 'east', 'west'] as const; +export type Face = (typeof FACES)[number]; + +/** Outward normal per face, in model space: x east, y up, z south. */ +const FACE_NORMAL: Record = { + up: [0, 1, 0], + down: [0, -1, 0], + north: [0, 0, -1], + south: [0, 0, 1], + east: [1, 0, 0], + west: [-1, 0, 0], +}; /** - * Fixed shade per face, standing in for Minecraft's own directional lighting. The values - * match vanilla's relative face brightness closely enough to read correctly. + * Shade per face, standing in for Minecraft's own directional lighting. The values are + * vanilla's: brightness depends on which way a face points, not on where the camera is, so + * turning the model shades the newly revealed faces the same way the game would. */ -const FACE_SHADE: Record = { +const FACE_SHADE: Record = { up: 1, + down: 0.5, + north: 0.8, south: 0.8, east: 0.6, + west: 0.6, }; +/** Camera angles, in radians. */ +export interface ViewAngles { + /** Rotation about the vertical axis. At 0 the camera faces the model's south side. */ + yaw: number; + /** Camera elevation. Positive looks down on the model, negative looks up at it. */ + pitch: number; +} + +/** + * The isometric view: yaw 45°, pitch 35.26°. Chosen so the three visible faces are equal + * on screen, which is the angle Minecraft's own inventory render uses. + */ +export const DEFAULT_VIEW: ViewAngles = { yaw: Math.PI / 4, pitch: Math.atan(Math.SQRT1_2) }; + +/** Straight down and straight up: past these the model would turn inside out. */ +export const MAX_PITCH = Math.PI / 2; + +/** + * Uniform zoom baked into the projection, picked so the default view reproduces the plain + * 2:1 isometric formula this preview used before it could rotate. + */ +const VIEW_SCALE = Math.sqrt(1.5); + +/** Fraction of the canvas the model is allowed to fill. The rest is breathing room. */ +const FIT_MARGIN = 0.92; + +/** Projects model space (0..16, y up) to 2D screen space under the given camera. */ +export function project( + x: number, + y: number, + z: number, + scale: number, + view: ViewAngles = DEFAULT_VIEW, +): [number, number] { + const cosYaw = Math.cos(view.yaw); + const sinYaw = Math.sin(view.yaw); + const cosPitch = Math.cos(view.pitch); + const sinPitch = Math.sin(view.pitch); + const k = scale * VIEW_SCALE; + + // Yaw turns the two horizontal axes into a screen-right component and a depth component; + // pitch then trades that depth against height for the vertical screen axis. + return [ + (x * cosYaw - z * sinYaw) * k, + ((x * sinYaw + z * cosYaw) * sinPitch - y * cosPitch) * k, + ]; +} + +/** Unit vector pointing from the model towards the camera. */ +export function viewVector(view: ViewAngles = DEFAULT_VIEW): Vec3 { + const cosPitch = Math.cos(view.pitch); + return [Math.sin(view.yaw) * cosPitch, Math.sin(view.pitch), Math.cos(view.yaw) * cosPitch]; +} + +/** + * The faces the camera can see. A face exactly edge-on is left out: it would be drawn as a + * zero-width sliver, and the face behind it says the same thing better. + */ +export function visibleFaces(view: ViewAngles = DEFAULT_VIEW): Face[] { + const towardsCamera = viewVector(view); + return FACES.filter((face) => dot(FACE_NORMAL[face], towardsCamera) > 1e-6); +} + +/** Applies a rotation to a view, keeping pitch within the range that stays right-side up. */ +export function turn(view: ViewAngles, yawDelta: number, pitchDelta: number): ViewAngles { + return { + yaw: view.yaw + yawDelta, + pitch: Math.min(MAX_PITCH, Math.max(-MAX_PITCH, view.pitch + pitchDelta)), + }; +} + export function parseModel(raw: unknown): ParsedModel | null { if (typeof raw !== 'object' || raw === null) return null; const model = raw as { elements?: ModelElement[]; textures?: Record }; @@ -70,12 +159,6 @@ export function parseModel(raw: unknown): ParsedModel | null { return elements.length > 0 ? { elements, textures } : null; } -/** Projects model space (0..16, y up) to 2D isometric screen space. */ -export function project(x: number, y: number, z: number, scale: number): [number, number] { - // Standard 2:1 isometric: x and z fan out sideways, y is vertical. - return [(x - z) * scale * 0.866, (x + z) * scale * 0.5 - y * scale]; -} - interface FaceCorners { /** Screen-space quad, ordered to match the texture's top-left, top-right, bottom-left. */ origin: [number, number]; @@ -84,59 +167,164 @@ interface FaceCorners { depth: number; } +interface Box { + x1: number; + y1: number; + z1: number; + x2: number; + y2: number; + z2: number; +} + +/** + * The three model-space corners that frame a face's texture: its origin, then the far end + * of the texture's u axis and of its v axis. + * + * The choice per face is vanilla's own UV convention — looking straight at a face, u runs + * right and v runs down. Getting this wrong is invisible on a fixed camera but obvious once + * the model turns, because a face and its opposite would then mirror each other. + */ +const FACE_CORNERS: Record [Vec3, Vec3, Vec3]> = { + up: ({ x1, y2, z1, x2, z2 }) => [ + [x1, y2, z1], + [x2, y2, z1], + [x1, y2, z2], + ], + down: ({ x1, y1, z1, x2, z2 }) => [ + [x1, y1, z2], + [x2, y1, z2], + [x1, y1, z1], + ], + north: ({ x1, y1, z1, x2, y2 }) => [ + [x2, y2, z1], + [x1, y2, z1], + [x2, y1, z1], + ], + south: ({ x1, y1, x2, y2, z2 }) => [ + [x1, y2, z2], + [x2, y2, z2], + [x1, y1, z2], + ], + east: ({ y1, z1, x2, y2, z2 }) => [ + [x2, y2, z2], + [x2, y2, z1], + [x2, y1, z2], + ], + west: ({ x1, y1, z1, y2, z2 }) => [ + [x1, y2, z1], + [x1, y2, z2], + [x1, y1, z1], + ], +}; + /** * Screen-space geometry for one face of one box. * - * `edgeU` and `edgeV` span the face from `origin`, which is exactly what an affine - * texture map needs. + * `edgeU` and `edgeV` span the face from `origin`, which is exactly what an affine texture + * map needs. `depth` measures how near the camera the face is, for the draw order. */ export function faceGeometry( element: { from: number[]; to: number[] }, - face: VisibleFace, + face: Face, scale: number, + view: ViewAngles = DEFAULT_VIEW, ): FaceCorners { - const [x1, y1, z1] = element.from; - const [x2, y2, z2] = element.to; - - const at = (x: number, y: number, z: number): [number, number] => project(x, y, z, scale); - - switch (face) { - case 'up': - return { - origin: at(x1, y2, z1), - edgeU: sub(at(x2, y2, z1), at(x1, y2, z1)), - edgeV: sub(at(x1, y2, z2), at(x1, y2, z1)), - depth: y2 + (x1 + z1) * 0.001, - }; - case 'south': - return { - origin: at(x1, y2, z2), - edgeU: sub(at(x2, y2, z2), at(x1, y2, z2)), - edgeV: sub(at(x1, y1, z2), at(x1, y2, z2)), - depth: z2 + (x1 + y1) * 0.001, - }; - case 'east': - return { - origin: at(x2, y2, z1), - edgeU: sub(at(x2, y2, z2), at(x2, y2, z1)), - edgeV: sub(at(x2, y1, z1), at(x2, y2, z1)), - depth: x2 + (y1 + z1) * 0.001, - }; - } + const [origin, uEnd, vEnd] = FACE_CORNERS[face](boxOf(element)); + + const projected = (point: Vec3): [number, number] => + project(point[0], point[1], point[2], scale, view); + + const screenOrigin = projected(origin); + + return { + origin: screenOrigin, + edgeU: sub(projected(uEnd), screenOrigin), + edgeV: sub(projected(vEnd), screenOrigin), + // The face's centre is the midpoint of the u-v diagonal; how far along the view + // direction it sits is what orders one face against another. + depth: dot(midpoint(uEnd, vEnd), viewVector(view)), + }; } -const sub = (a: [number, number], b: [number, number]): [number, number] => [ - a[0] - b[0], - a[1] - b[1], -]; +/** Draw order: far faces first, so nearer boxes paint over them. */ +export function drawOrder( + model: ParsedModel, + scale: number, + view: ViewAngles = DEFAULT_VIEW, +): Array<{ element: ParsedModel['elements'][number]; face: Face; geometry: FaceCorners }> { + const faces: Array<{ + element: ParsedModel['elements'][number]; + face: Face; + geometry: FaceCorners; + }> = []; -/** Bounding box of a model in screen space, used to centre and fit the drawing. */ -export function screenBounds(model: ParsedModel, scale: number) { + for (const element of model.elements) { + for (const face of visibleFaces(view)) { + faces.push({ element, face, geometry: faceGeometry(element, face, scale, view) }); + } + } + + return faces.sort((a, b) => a.geometry.depth - b.geometry.depth); +} + +/** Bounding box of a model in screen space, under one camera angle. */ +export function screenBounds(model: ParsedModel, scale: number, view: ViewAngles = DEFAULT_VIEW) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const corner of corners(model)) { + const [sx, sy] = project(corner[0], corner[1], corner[2], scale, view); + minX = Math.min(minX, sx); + minY = Math.min(minY, sy); + maxX = Math.max(maxX, sx); + maxY = Math.max(maxY, sy); + } + + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; +} + +/** Model-space centre of the whole model: the point a rotation turns about. */ +export function modelCentre(model: ParsedModel): Vec3 { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + + for (const corner of corners(model)) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], corner[axis]); + max[axis] = Math.max(max[axis], corner[axis]); + } + } + + return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; +} + +/** + * Screen-space radius, at scale 1, that holds the model whichever way it is turned. + * + * Fitting to the bounding box of the current angle instead would be tighter, but the model + * would then swell and shrink as it rotates, which reads as the preview being broken. The + * projection is an orthographic view of a rigid rotation, so the sphere around the model + * projects to a circle of this radius at every angle. + */ +export function viewRadius(model: ParsedModel): number { + const centre = modelCentre(model); + let radius = 0; + + for (const corner of corners(model)) { + radius = Math.max(radius, Math.hypot(...sub3(corner, centre))); + } + + return radius * VIEW_SCALE; +} + +/** Scale that fits the model to a square canvas of `size`, at any camera angle. */ +export function fitScale(model: ParsedModel, size: number): number { + return (size * FIT_MARGIN) / Math.max(viewRadius(model) * 2, 1); +} + +function* corners(model: ParsedModel): Generator { for (const element of model.elements) { const [x1, y1, z1] = element.from; const [x2, y2, z2] = element.to; @@ -144,30 +332,35 @@ export function screenBounds(model: ParsedModel, scale: number) { for (const x of [x1, x2]) { for (const y of [y1, y2]) { for (const z of [z1, z2]) { - const [sx, sy] = project(x, y, z, scale); - minX = Math.min(minX, sx); - minY = Math.min(minY, sy); - maxX = Math.max(maxX, sx); - maxY = Math.max(maxY, sy); + yield [x, y, z]; } } } } - - return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; } -/** Draw order: far faces first, so nearer boxes paint over them. */ -export function drawOrder(model: ParsedModel, scale: number) { - const faces: Array<{ element: ParsedModel['elements'][number]; face: VisibleFace; geometry: FaceCorners }> = []; +const boxOf = (element: { from: number[]; to: number[] }): Box => ({ + x1: element.from[0], + y1: element.from[1], + z1: element.from[2], + x2: element.to[0], + y2: element.to[1], + z2: element.to[2], +}); - for (const element of model.elements) { - for (const face of VISIBLE_FACES) { - faces.push({ element, face, geometry: faceGeometry(element, face, scale) }); - } - } +const sub = (a: [number, number], b: [number, number]): [number, number] => [ + a[0] - b[0], + a[1] - b[1], +]; - return faces.sort((a, b) => a.geometry.depth - b.geometry.depth); -} +const sub3 = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + +const midpoint = (a: Vec3, b: Vec3): Vec3 => [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + (a[2] + b[2]) / 2, +]; + +const dot = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -export { FACE_SHADE, VISIBLE_FACES }; +export { FACE_SHADE, FACES }; diff --git a/launcher/src/shared/project.ts b/launcher/src/shared/project.ts index ca7648a..e435f00 100644 --- a/launcher/src/shared/project.ts +++ b/launcher/src/shared/project.ts @@ -34,11 +34,35 @@ export interface EllaProject { formatVersion: number; name: string; namespace: string; - targetVersions: string[]; + /** + * The Minecraft version this project is authored against, or null until a launch binds + * one. It is what the launcher preselects when the project is opened, and what a launch + * on any other version is checked against — see shared/version-compat.ts. + */ + targetVersion: string | null; slotPool: Record; entries: ProjectEntry[]; } +/** + * Fills in a manifest read from disk. + * + * `targetVersion` replaced a `targetVersions` array that nothing ever read or wrote past + * creation. Projects written before the change carry the array, so its first entry is + * adopted rather than discarded: it was the version the author picked when they created + * the project, which is exactly what the field now means. + */ +export function withDefaults(project: EllaProject): EllaProject { + if (typeof project.targetVersion === 'string' || project.targetVersion === null) { + return project; + } + + const legacy = (project as { targetVersions?: unknown }).targetVersions; + const adopted = Array.isArray(legacy) && typeof legacy[0] === 'string' ? legacy[0] : null; + + return { ...project, targetVersion: adopted }; +} + // --------------------------------------------------------------------------- // Identifier rules // --------------------------------------------------------------------------- @@ -71,7 +95,7 @@ export function emptyProject(name: string, namespace: string): EllaProject { formatVersion: PROJECT_FORMAT_VERSION, name, namespace, - targetVersions: [], + targetVersion: null, slotPool: { block: 128, item: 128 }, entries: [], }; diff --git a/launcher/src/shared/version-compat.ts b/launcher/src/shared/version-compat.ts new file mode 100644 index 0000000..bf645a5 --- /dev/null +++ b/launcher/src/shared/version-compat.ts @@ -0,0 +1,240 @@ +/** + * What moving a project from one Minecraft version to another does to the files in it. + * + * Most of a resource pack is version-independent, and deliberately so: an element's + * geometry, its UVs, its display transforms and a project's references to its *own* + * textures have read the same way from 1.8 to 1.21. Two things have not, and both fail + * silently — the game loads the file, reports nothing, and draws the wrong thing: + * + * - **A `parent` overrides the model's own geometry below 1.9.** The model renders as + * whatever it inherits from. See model-compat.ts for why, in full. + * - **Vanilla's texture folders were renamed in 1.13.** `textures/blocks` became + * `textures/block` and `textures/items` became `textures/item`, so a model that borrows + * a vanilla texture — `minecraft:blocks/stone`, which is what Blockbench writes when + * you pick one on 1.12 — resolves on one side of that line and shows the missing-texture + * checkerboard on the other. + * + * A project's own textures are deliberately left alone. A resource pack may keep them under + * any folder it likes: the model's reference *is* the path, and Ella always writes + * `:block/` with a file to match, which resolves identically on every + * version. "Fixing" those to follow vanilla's rename would break the one thing that already + * works everywhere. + * + * Everything here is pure. Reading the files and writing them back is main/version-change.ts. + */ + +import { baseVersionOf, isAtLeast, isBelow } from './version.ts'; +import { + findParentTrap, + withoutParent, + PARENT_OVERRIDES_ELEMENTS_BELOW, +} from './model-compat.ts'; + +/** The release that renamed vanilla's `textures/blocks` and `textures/items` folders. */ +export const VANILLA_FOLDER_RENAME = '1.13'; + +export type CompatIssueId = 'parentOverridesGeometry' | 'vanillaTextureFolder'; + +export interface CompatIssue { + id: CompatIssueId; + /** True when {@link migrateModel} can rewrite the model itself. */ + fixable: boolean; + /** Values for the message describing it. */ + detail: Record; +} + +// --------------------------------------------------------------------------- +// Vanilla texture references +// --------------------------------------------------------------------------- + +/** Folder each era wants, keyed by the name the other era used. */ +const FOLDER_FOR: Record<'modern' | 'legacy', Record> = { + modern: { blocks: 'block', items: 'item' }, + legacy: { block: 'blocks', item: 'items' }, +}; + +const eraOf = (target: string): 'modern' | 'legacy' => + isAtLeast(baseVersionOf(target), VANILLA_FOLDER_RENAME) ? 'modern' : 'legacy'; + +/** The folders vanilla keeps its block and item textures in, on a given version. */ +export function vanillaTextureFolders(target: string): { block: string; item: string } { + return eraOf(target) === 'modern' + ? { block: 'block', item: 'item' } + : { block: 'blocks', item: 'items' }; +} + +/** + * The same texture reference written the way `target` expects it, or null when it already + * is — or when it is not a vanilla block/item texture at all. + * + * `#name` is a reference to another texture variable rather than to a file, and anything + * outside the `minecraft` namespace belongs to a pack that decides its own layout. + */ +export function retargetVanillaTexture(reference: string, target: string): string | null { + if (reference.startsWith('#')) return null; + + const colon = reference.indexOf(':'); + // An unqualified reference means vanilla, which is exactly the case that needs fixing. + const namespace = colon === -1 ? 'minecraft' : reference.slice(0, colon); + const location = colon === -1 ? reference : reference.slice(colon + 1); + if (namespace !== 'minecraft') return null; + + const slash = location.indexOf('/'); + if (slash === -1) return null; + + const wanted = FOLDER_FOR[eraOf(target)][location.slice(0, slash)]; + if (!wanted) return null; + + const rewritten = `${wanted}/${location.slice(slash + 1)}`; + return colon === -1 ? rewritten : `minecraft:${rewritten}`; +} + +/** Every texture variable in a model whose vanilla reference is written for the wrong era. */ +function staleVanillaTextures( + model: unknown, + target: string, +): Array<{ key: string; from: string; to: string }> { + const textures = (model as { textures?: unknown }).textures; + if (typeof textures !== 'object' || textures === null) return []; + + const stale: Array<{ key: string; from: string; to: string }> = []; + for (const [key, value] of Object.entries(textures as Record)) { + if (typeof value !== 'string') continue; + const retargeted = retargetVanillaTexture(value, target); + if (retargeted) stale.push({ key, from: value, to: retargeted }); + } + return stale; +} + +// --------------------------------------------------------------------------- +// Inspection and migration +// --------------------------------------------------------------------------- + +/** Everything about one model that would go wrong on `target`. */ +export function inspectModel(model: unknown, target: string): CompatIssue[] { + if (typeof model !== 'object' || model === null) return []; + + const issues: CompatIssue[] = []; + const version = baseVersionOf(target); + + const trap = findParentTrap(model); + if (trap && isBelow(version, PARENT_OVERRIDES_ELEMENTS_BELOW)) { + issues.push({ id: 'parentOverridesGeometry', fixable: true, detail: { parent: trap.parent } }); + } + + const stale = staleVanillaTextures(model, version); + if (stale.length > 0) { + // One issue per model rather than per reference: they are the same mistake made once, + // they are fixed in the same pass, and a row each would bury the rest of the report. + // `count` carries the rest, so the UI can say how many without listing them. + issues.push({ + id: 'vanillaTextureFolder', + fixable: true, + detail: { count: stale.length, reference: stale[0].from, expected: stale[0].to }, + }); + } + + return issues; +} + +/** + * Rewrites a model so it renders on `target`, returning a new object. + * + * Both rewrites are safe in either direction and on any version: dropping a parent leaves a + * model that already had its own geometry self-contained, and a vanilla texture reference + * has exactly one correct spelling per era. That is what makes offering this at launch + * reasonable rather than reckless — nothing here is a guess about intent. + */ +export function migrateModel( + model: Record, + target: string, +): { model: Record; applied: CompatIssueId[] } { + let next = model; + const applied: CompatIssueId[] = []; + + for (const issue of inspectModel(model, target)) { + if (!issue.fixable) continue; + + if (issue.id === 'parentOverridesGeometry') { + next = withoutParent(next); + applied.push(issue.id); + } + + if (issue.id === 'vanillaTextureFolder') { + const textures = { ...(next.textures as Record) }; + for (const { key, to } of staleVanillaTextures(next, baseVersionOf(target))) { + textures[key] = to; + } + next = { ...next, textures }; + applied.push(issue.id); + } + } + + return { model: next, applied }; +} + +// --------------------------------------------------------------------------- +// Consequences that are not about the project's files +// --------------------------------------------------------------------------- + +export type CompatNoteId = + | 'notInstalled' + | 'noAdapter' + | 'plannedAdapter' + | 'javaMissing' + | 'losesLiveEditing'; + +export interface CompatNote { + id: CompatNoteId; + detail: Record; +} + +/** The part of a version summary these notes are drawn from. */ +export interface VersionFacts { + id: string; + installed: boolean; + adapterStatus: 'built' | 'planned' | null; + javaAvailable: boolean; + requiredJava: number; +} + +/** + * What launching `to` costs, beyond the files. Every note is a warning — one that was not + * would have no business in a confirmation dialog. + * + * `from` is the version the project is bound to, and is only used to say what is being + * given up: losing live editing matters far more to someone who had it a moment ago than + * to someone who never did. + */ +export function versionChangeNotes(from: VersionFacts | null, to: VersionFacts): CompatNote[] { + const notes: CompatNote[] = []; + + if (!to.installed) { + notes.push({ id: 'notInstalled', detail: { version: to.id } }); + } + + if (to.adapterStatus === null) { + notes.push({ id: 'noAdapter', detail: { version: to.id } }); + } else if (to.adapterStatus === 'planned') { + notes.push({ id: 'plannedAdapter', detail: { version: to.id } }); + } + + if (from?.adapterStatus === 'built' && to.adapterStatus !== 'built') { + notes.push({ id: 'losesLiveEditing', detail: { from: from.id, to: to.id } }); + } + + if (!to.javaAvailable) { + notes.push({ id: 'javaMissing', detail: { version: to.id, java: to.requiredJava } }); + } + + return notes; +} + +/** + * Whether launching `to` is a version change worth stopping for. + * + * An unbound project is not: the first launch is what binds it, and asking someone to + * confirm a change away from nothing would be a dialog with no decision in it. + */ +export const isVersionChange = (from: string | null, to: string): boolean => + from !== null && from !== to; diff --git a/launcher/src/shared/version.ts b/launcher/src/shared/version.ts index 942cf56..6a6a736 100644 --- a/launcher/src/shared/version.ts +++ b/launcher/src/shared/version.ts @@ -189,7 +189,16 @@ export interface AdapterCoverage { * "compiles for 1.21.1" says nothing about 1.21.11. */ export const ADAPTERS: AdapterCoverage[] = [ - { id: 'forge-1.8.9', min: '1.8', max: '1.9', status: 'planned' }, + /* + * Starts at 1.8.8, not 1.8. + * + * The jar is compiled against 1.8.9 and reobfuscated to SRG names, so what it covers is + * decided by the mappings rather than by the version number. Every method and field the + * adapter overrides maps to the same obfuscated target in 1.8.8 and 1.8.9 — checked + * against both `joined.srg` files, not assumed from the versions being adjacent. In 1.8 + * itself every one of them differs, which is why the range stops short of it. + */ + { id: 'forge-1.8.9', min: '1.8.8', max: '1.9', status: 'built' }, { id: 'forge-1.12.2', min: '1.12', max: '1.13', status: 'built' }, { id: 'forge-mid', min: '1.16', max: '1.20.2', status: 'planned' }, { id: 'forge-modern', min: '1.21.1', max: '1.21.2', status: 'built' }, diff --git a/launcher/src/shared/workflow.ts b/launcher/src/shared/workflow.ts new file mode 100644 index 0000000..5ea74f5 --- /dev/null +++ b/launcher/src/shared/workflow.ts @@ -0,0 +1,72 @@ +/** + * The setup path, derived from live state rather than remembered. + * + * Ella's loop only works once five things are true at the same time, and the order they + * have to happen in is not guessable from the navigation: a version installed, a project + * open, something in it, Blockbench reachable, and the game running. Nothing here is a + * checkbox the user ticks — every step reads the same state the rest of the UI reads, so + * it can never claim something is done when it is not, and a step that stops being true + * (the game exits, the project is closed) goes back to undone on its own. + * + * Steps are never hidden once complete. Seeing the whole path, with the finished part + * behind you, is what makes the order obvious the first time and reassuring after that. + */ + +export type WorkflowStepId = 'version' | 'project' | 'entry' | 'blockbench' | 'launch'; + +/** Everything the path depends on, flattened out of the session and config. */ +export interface WorkflowFacts { + installedVersions: number; + /** Installed versions whose adapter jar exists — the only ones that can live-edit. */ + liveEditingVersions: number; + hasProject: boolean; + entryCount: number; + /** Whether Ella can find a Blockbench to launch, configured or auto-detected. */ + blockbenchFound: boolean; + /** The mod has completed its handshake, so edits reach the game. */ + connected: boolean; +} + +export interface WorkflowStep { + id: WorkflowStepId; + done: boolean; + /** The first unfinished step: the one thing to do next, and the only accented control. */ + current: boolean; +} + +/** Fixed order — this is the sequence, not a set of independent chores. */ +const ORDER: WorkflowStepId[] = ['version', 'project', 'entry', 'blockbench', 'launch']; + +export function workflowSteps(facts: WorkflowFacts): WorkflowStep[] { + const done: Record = { + version: facts.installedVersions > 0, + project: facts.hasProject, + entry: facts.hasProject && facts.entryCount > 0, + blockbench: facts.blockbenchFound, + launch: facts.connected, + }; + + // Exactly one step is current, and only the earliest unfinished one. Marking every + // unfinished step would be the same as marking none. + const first = ORDER.find((id) => !done[id]); + + return ORDER.map((id) => ({ id, done: done[id], current: id === first })); +} + +export const isSetupComplete = (steps: WorkflowStep[]): boolean => + steps.every((step) => step.done); + +export const currentStep = (steps: WorkflowStep[]): WorkflowStep | null => + steps.find((step) => step.current) ?? null; + +export const completedCount = (steps: WorkflowStep[]): number => + steps.filter((step) => step.done).length; + +/** + * Whether the versions on this machine can live-edit at all. + * + * Distinct from having no version installed: someone can install 1.19.2, see everything + * work, and never understand why edits do not appear. Naming it as its own condition lets + * the UI say so before the launch rather than after. + */ +export const canLiveEdit = (facts: WorkflowFacts): boolean => facts.liveEditingVersions > 0; diff --git a/launcher/test/adapter-ranges.test.ts b/launcher/test/adapter-ranges.test.ts new file mode 100644 index 0000000..bd4d7fc --- /dev/null +++ b/launcher/test/adapter-ranges.test.ts @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ADAPTERS, type AdapterId } from '../src/shared/version.ts'; + +/* + * Keeps ADAPTERS in step with what each adapter tells its own loader. + * + * The README claims this table prevents the launcher offering a version the game then + * refuses the mod on. It did not: widening the 1.8 bucket to 1.8.8 left the mod still + * declaring 1.8.9 to FML, and the game rejected it with "Ella (ella) wants Minecraft + * [1.8.9,1.8.9]" on a version the launcher had just advertised as live-editable. Nothing + * in the build could catch that, because the two facts live in different languages in + * different directories. + * + * Read as text rather than parsed: these are Java and TOML, and a regex over a line that + * has to be written literally anyway is enough to notice it changing. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** Where each built adapter states the range it accepts, and how to find it in the file. */ +const DECLARATIONS: Record = { + 'forge-1.8.9': { + file: 'mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java', + pattern: /acceptedMinecraftVersions\s*=\s*"([^"]+)"/, + }, + 'forge-1.12.2': { + file: 'mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java', + pattern: /acceptedMinecraftVersions\s*=\s*"([^"]+)"/, + }, + 'forge-modern': { + file: 'mod/adapters/forge-modern/src/main/resources/META-INF/mods.toml', + // The minecraft dependency's range, not the loader's. + pattern: /versionRange\s*=\s*"(\[1\.[^"]+)"/, + }, +}; + +/** ADAPTERS uses an inclusive minimum and an exclusive maximum, which is Maven's `[a,b)`. */ +const expectedRange = (min: string, max: string): string => `[${min},${max})`; + +for (const coverage of ADAPTERS.filter((entry) => entry.status === 'built')) { + test(`${coverage.id} declares the range the launcher advertises`, async () => { + const declaration = DECLARATIONS[coverage.id as AdapterId]; + assert.ok(declaration, `no declaration site recorded for ${coverage.id}`); + + const source = await readFile(path.join(repoRoot, declaration.file), 'utf8'); + const match = declaration.pattern.exec(source); + + assert.ok( + match, + `${declaration.file} states no accepted Minecraft range, so the loader will infer ` + + 'one from the exact version and refuse everything else in the bucket', + ); + + assert.equal( + match[1], + expectedRange(coverage.min, coverage.max), + `${coverage.id} accepts ${match[1]} but the launcher offers ` + + `${expectedRange(coverage.min, coverage.max)}`, + ); + }); +} + +test('every built adapter has a declaration site to check', () => { + // A new adapter must not pass this file silently by being absent from it. + for (const coverage of ADAPTERS.filter((entry) => entry.status === 'built')) { + assert.ok( + coverage.id in DECLARATIONS, + `${coverage.id} is built but this test does not know where it declares its range`, + ); + } +}); diff --git a/launcher/test/app.test.ts b/launcher/test/app.test.ts new file mode 100644 index 0000000..35dae45 --- /dev/null +++ b/launcher/test/app.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { APP_NAME, APP_VERSION } from '../src/shared/app.ts'; + +test('the version shown in the app is the version that was built', async () => { + // APP_VERSION is written out rather than read from the manifest, because the renderer has + // no filesystem. This is what stops the two drifting: a release bumps package.json, and + // this fails until the constant follows — before a build ships a sidebar and a crash + // report claiming the wrong build. + const manifest = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ) as { name: string; version: string }; + + assert.equal(APP_VERSION, manifest.version); +}); + +test('the version is a version', async () => { + assert.match(APP_VERSION, /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/); +}); + +test('the launcher names itself the same way everywhere', () => { + // Minecraft is told this name in its launch arguments and it appears in the window, so a + // stray rename in one place would show up in a log nobody thinks to distrust. + assert.equal(APP_NAME, 'Ella'); +}); diff --git a/launcher/test/forge-install.test.ts b/launcher/test/forge-install.test.ts new file mode 100644 index 0000000..3e4844d --- /dev/null +++ b/launcher/test/forge-install.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isLegacyProfile } from '../src/main/minecraft/forge.ts'; + +/* + * The two installer generations, as they actually appear on maven.minecraftforge.net. + * + * Forge builds before 2018 have no `--installClient`: passing it aborts with + * "'installClient' is not a recognized option", which is what made every 1.8.x install + * fail while still reporting the version as installed. Those builds also predate the + * install processors, so unpacking them directly is complete rather than a shortcut. + * + * The fields below are trimmed from the real profiles of forge 11.15.0.1655 (1.8.8) and + * 14.23.5.2859 (1.12.2). + */ + +const LEGACY = { + install: { + profileName: 'forge', + target: '1.8.8-forge1.8.8-11.15.0.1655', + path: 'net.minecraftforge:forge:1.8.8-11.15.0.1655', + filePath: 'forge-1.8.8-11.15.0.1655-universal.jar', + minecraft: '1.8.8', + }, + versionInfo: { + id: '1.8.8-forge1.8.8-11.15.0.1655', + inheritsFrom: '1.8.8', + mainClass: 'net.minecraft.launchwrapper.Launch', + libraries: [], + }, +}; + +const MODERN = { + spec: 0, + profile: 'forge', + version: '1.12.2-forge-14.23.5.2859', + json: '/version.json', + path: 'net.minecraftforge:forge:1.12.2-14.23.5.2859', + minecraft: '1.12.2', + data: {}, + processors: [{ jar: 'net.minecraftforge:installertools:1.1.6', args: [] }], + libraries: [], +}; + +test('the pre-2018 installer layout is recognised', () => { + assert.equal(isLegacyProfile(LEGACY), true); +}); + +test('a processor-driven installer is left to the official installer', () => { + // Unpacking one of these by hand would skip the binary patching and deobfuscation, and + // produce a version that installs cleanly then crashes on launch. + assert.equal(isLegacyProfile(MODERN), false); +}); + +test('anything unreadable falls through to the installer', () => { + assert.equal(isLegacyProfile(null), false); + assert.equal(isLegacyProfile(undefined), false); + assert.equal(isLegacyProfile('install_profile.json'), false); + assert.equal(isLegacyProfile({}), false); +}); + +test('a half-formed legacy profile is not treated as installable', () => { + // Every field is used during the unpack, so a missing one has to disqualify the whole + // profile rather than fail partway through with files already written. + assert.equal(isLegacyProfile({ install: LEGACY.install }), false); + assert.equal(isLegacyProfile({ versionInfo: LEGACY.versionInfo }), false); + assert.equal( + isLegacyProfile({ install: { path: LEGACY.install.path }, versionInfo: LEGACY.versionInfo }), + false, + 'a profile with no universal jar to file away', + ); + assert.equal( + isLegacyProfile({ install: LEGACY.install, versionInfo: { inheritsFrom: '1.8.8' } }), + false, + 'a profile with no version id to install under', + ); +}); diff --git a/launcher/test/model-compat.test.ts b/launcher/test/model-compat.test.ts new file mode 100644 index 0000000..37b4929 --- /dev/null +++ b/launcher/test/model-compat.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { findParentTrap, withoutParent } from '../src/shared/model-compat.ts'; +import { defaultBlockModel } from '../src/main/pack.ts'; + +/* + * The failure these guard, in full. + * + * 1.8.9's ModelBlock.getElements() is `hasParent() ? parent.getElements() : elements`, + * with hasParent() being `parent != null`. The parent wins outright — which is why vanilla + * 1.8.9's own block/cube.json declares no parent and inlines its elements, and only gained + * `"parent": "block/block"` in 1.9 once the child started winning. + * + * Blockbench keeps whatever parent it finds. A model that starts as `cube_all` and then + * gains geometry therefore renders in game as a plain cube in the missing-texture + * checkerboard: the author's shapes are discarded, and the parent's `#all` no longer + * resolves because Blockbench rewrote the texture keys. + */ + +const BLOCKBENCH_OUTPUT = { + format_version: '1.21.11', + parent: 'block/cube_all', + textures: { 0: 'proj:block/thing', particle: 'proj:block/thing' }, + elements: [{ from: [0, 5, 7], to: [9, 7, 9], faces: {} }], +}; + +test('a parent over the model’s own geometry is reported', () => { + const trap = findParentTrap(BLOCKBENCH_OUTPUT); + assert.deepEqual(trap, { parent: 'block/cube_all', elementCount: 1 }); +}); + +test('inheriting geometry is normal and not reported', () => { + // The everyday case: no elements of its own, so the parent is doing its job. + assert.equal(findParentTrap({ parent: 'block/cube_all', textures: { all: 'a:b' } }), null); + // Ella's own slot redirect is exactly this shape and must never be flagged. + assert.equal(findParentTrap({ parent: 'proj:block/thing' }), null); +}); + +test('a self-contained model is not reported', () => { + assert.equal(findParentTrap({ elements: [{ from: [0, 0, 0], to: [16, 16, 16] }] }), null); +}); + +test('malformed input is not mistaken for a trap', () => { + for (const value of [null, undefined, 'model', 42, [], {}]) { + assert.equal(findParentTrap(value), null, `${JSON.stringify(value)} should be ignored`); + } + // An empty elements array means the parent still supplies the geometry. + assert.equal(findParentTrap({ parent: 'block/cube_all', elements: [] }), null); + assert.equal(findParentTrap({ parent: '', elements: [{}] }), null); +}); + +test('the fix removes only the parent', () => { + const fixed = withoutParent(BLOCKBENCH_OUTPUT); + + assert.equal('parent' in fixed, false); + assert.equal(findParentTrap(fixed), null, 'the fixed model must not trip the check again'); + assert.deepEqual(fixed.elements, BLOCKBENCH_OUTPUT.elements); + assert.deepEqual(fixed.textures, BLOCKBENCH_OUTPUT.textures); + assert.equal(fixed.format_version, '1.21.11', 'unrelated keys Blockbench needs stay'); + assert.equal('parent' in BLOCKBENCH_OUTPUT, true, 'the input is not mutated'); +}); + +test('the generated starting model cannot inherit the trap', () => { + // A new block starts self-contained precisely so Blockbench has no parent to keep. + const model = JSON.parse(defaultBlockModel('proj:block/thing')); + + assert.equal('parent' in model, false); + assert.ok(model.elements.length > 0, 'it has to draw something on its own'); + assert.equal(findParentTrap(model), null); + + // Still a full cube, which is what inheriting from cube_all used to provide. + assert.deepEqual(model.elements[0].from, [0, 0, 0]); + assert.deepEqual(model.elements[0].to, [16, 16, 16]); + assert.equal(model.textures.all, 'proj:block/thing'); + assert.equal(model.textures.particle, 'proj:block/thing', 'break particles need a texture'); +}); diff --git a/launcher/test/model-preview.test.ts b/launcher/test/model-preview.test.ts index 62024dc..ab7ae8f 100644 --- a/launcher/test/model-preview.test.ts +++ b/launcher/test/model-preview.test.ts @@ -6,7 +6,15 @@ import { faceGeometry, screenBounds, drawOrder, + visibleFaces, + viewRadius, + fitScale, + modelCentre, + turn, + DEFAULT_VIEW, + MAX_PITCH, FACE_SHADE, + type ViewAngles, } from '../src/shared/model-preview.ts'; const CUBE = { from: [0, 0, 0], to: [16, 16, 16], faces: {} }; @@ -104,9 +112,135 @@ test('every visible face of every element is drawn', () => { assert.equal(drawOrder(model, 1).length, 6); }); -test('shading distinguishes the three visible faces', () => { - // Identical shading would make a cube read as a flat hexagon. - const values = Object.values(FACE_SHADE); - assert.equal(new Set(values).size, values.length); +test('shading distinguishes the faces visible at once', () => { + // Identical shading would make a cube read as a flat hexagon. Opposite faces may share a + // shade — vanilla gives them one — because no camera sees both. + const shades = visibleFaces(DEFAULT_VIEW).map((face) => FACE_SHADE[face]); + assert.equal(new Set(shades).size, shades.length); assert.equal(FACE_SHADE.up, 1, 'the lit face is unshaded'); }); + +test('the default view sees the top, south and east faces', () => { + assert.deepEqual(visibleFaces(DEFAULT_VIEW).sort(), ['east', 'south', 'up']); +}); + +test('turning half way round shows the opposite faces', () => { + const behind = turn(DEFAULT_VIEW, Math.PI, 0); + assert.deepEqual(visibleFaces(behind).sort(), ['north', 'up', 'west']); +}); + +test('looking from below swaps the top face for the bottom one', () => { + const under = turn(DEFAULT_VIEW, 0, -2 * DEFAULT_VIEW.pitch); + assert.ok(visibleFaces(under).includes('down')); + assert.ok(!visibleFaces(under).includes('up')); +}); + +test('a face seen exactly edge-on is not drawn', () => { + // At yaw 0 the camera is square on to the south face, so east and west are slivers. + const faces = visibleFaces({ yaw: 0, pitch: DEFAULT_VIEW.pitch }); + assert.deepEqual(faces.sort(), ['south', 'up']); +}); + +test('pitch cannot go past straight up or straight down', () => { + assert.equal(turn(DEFAULT_VIEW, 0, 10).pitch, MAX_PITCH); + assert.equal(turn(DEFAULT_VIEW, 0, -10).pitch, -MAX_PITCH); +}); + +test('yaw is free to wind past a full turn', () => { + // Clamping it would make a drag stick at an arbitrary angle mid-gesture. + const spun = turn(DEFAULT_VIEW, 4 * Math.PI, 0); + assert.ok(Math.abs(spun.yaw - DEFAULT_VIEW.yaw - 4 * Math.PI) < 1e-9); +}); + +test('every visible face of every element is drawn, at any angle', () => { + const model = parseModel({ elements: [CUBE, { from: [0, 0, 0], to: [8, 8, 8], faces: {} }] })!; + const view = turn(DEFAULT_VIEW, 0.7, -0.2); + + assert.equal(drawOrder(model, 1, view).length, visibleFaces(view).length * 2); +}); + +test('draw order stays back to front after a rotation', () => { + const model = parseModel({ + elements: [ + { from: [0, 0, 0], to: [4, 4, 4], faces: {} }, + { from: [12, 12, 12], to: [16, 16, 16], faces: {} }, + ], + })!; + + for (const yaw of [0, 1, 2, 3, 4, 5]) { + const order = drawOrder(model, 1, { yaw, pitch: 0.3 }); + const depths = order.map((face) => face.geometry.depth); + + for (let i = 1; i < depths.length; i++) { + assert.ok(depths[i] >= depths[i - 1], `depths must be non-decreasing at yaw ${yaw}`); + } + } +}); + +test('no face is ever drawn mirrored', () => { + // A face is textured by mapping the unit square onto (edgeU, edgeV); if that pair winds + // the wrong way round, the texture comes out flipped. Invisible on a fixed camera, and + // glaring the moment the model can be turned. + for (const view of samples()) { + for (const face of visibleFaces(view)) { + const { edgeU, edgeV } = faceGeometry(CUBE, face, 1, view); + const determinant = edgeU[0] * edgeV[1] - edgeU[1] * edgeV[0]; + + assert.ok(determinant > 0, `${face} is mirrored at yaw ${view.yaw}, pitch ${view.pitch}`); + } + } +}); + +test('a face and its opposite are not mirror images of each other', () => { + // Both textures are read left to right, so their screen u axes must point opposite ways — + // the bug you only notice once the model can turn. + const east = faceGeometry(CUBE, 'east', 1); + const west = faceGeometry(CUBE, 'west', 1); + + assert.ok(east.edgeU[0] * west.edgeU[0] + east.edgeU[1] * west.edgeU[1] < 0); + + const north = faceGeometry(CUBE, 'north', 1); + const south = faceGeometry(CUBE, 'south', 1); + + assert.ok(north.edgeU[0] * south.edgeU[0] + north.edgeU[1] * south.edgeU[1] < 0); +}); + +test('the model turns about its own centre', () => { + const model = parseModel({ elements: [{ from: [4, 0, 4], to: [12, 6, 12], faces: {} }] })!; + assert.deepEqual(modelCentre(model), [8, 3, 8]); +}); + +test('the fitted size does not change as the model turns', () => { + // A preview that swells and shrinks mid-drag reads as broken, so the fit has to hold the + // model at every angle rather than at the current one. + const model = parseModel({ elements: [CUBE] })!; + const scale = fitScale(model, 100); + + for (const view of samples()) { + const bounds = screenBounds(model, scale, view); + assert.ok(bounds.width <= 100 && bounds.height <= 100, 'the model never leaves the canvas'); + } +}); + +test('the fit is tight enough to be worth the canvas', () => { + const model = parseModel({ elements: [CUBE] })!; + const scale = fitScale(model, 100); + const widest = Math.max(...samples().map((view) => screenBounds(model, scale, view).height)); + + assert.ok(widest > 80, 'some angle fills most of the canvas'); +}); + +test('a flat model fits as well as a bulky one', () => { + const plate = parseModel({ elements: [{ from: [0, 0, 7], to: [16, 16, 9], faces: {} }] })!; + assert.ok(viewRadius(plate) < viewRadius(parseModel({ elements: [CUBE] })!)); +}); + +function samples(): ViewAngles[] { + const views: ViewAngles[] = []; + for (let yaw = 0; yaw < 12; yaw++) { + for (let pitch = -3; pitch <= 3; pitch++) { + views.push({ yaw: (yaw * Math.PI) / 6, pitch: (pitch * MAX_PITCH) / 3 }); + } + } + return views; +} diff --git a/launcher/test/pack-project.test.ts b/launcher/test/pack-project.test.ts index 2ca4ce5..bbca0e2 100644 --- a/launcher/test/pack-project.test.ts +++ b/launcher/test/pack-project.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { inflateSync } from 'node:zlib'; import path from 'node:path'; @@ -10,6 +10,7 @@ import { slotRedirectModel, buildLangFiles, writeSlotNamespace, + writeEntrySlot, packMcmeta, fallbackPackFormat, SLOT_NAMESPACE, @@ -254,6 +255,86 @@ test('regenerating the slot namespace removes stale files', async () => { assert.deepEqual(await readdir(blockstates), ['block_000.json']); }); +// --------------------------------------------------------------------------- +// Targeted slot writes +// --------------------------------------------------------------------------- + +/** + * A settings change takes the cheap path: one slot rewritten rather than the whole + * namespace. These pin the two properties that makes safe — it writes the slot it was + * given, and it leaves every other slot exactly as it was. + */ +test('writing one entry slot updates only that slot', async () => { + const root = path.join(workspace, 'targeted'); + const base = { ...emptyProject('P', 'proj'), slotPool: { block: 3, item: 1 } }; + + const project: typeof base = { + ...base, + entries: [ + { + ...base.entries[0], + id: 'lamp', + kind: 'block', + slot: 1, + displayName: { en: 'Lamp' }, + settings: { renderLayer: 'solid' }, + } as (typeof base.entries)[number], + ], + }; + + await writeSlotNamespace(root, project, 34); + + const modelAt = (slot: number): string => + path.join(root, `pack/assets/ella/models/block/slot_${String(slot).padStart(3, '0')}.json`); + + const untouchedBefore = await readFile(modelAt(0), 'utf8'); + + const patched = { + ...project.entries[0], + settings: { renderLayer: 'translucent' }, + }; + const written = await writeEntrySlot(root, project, patched); + + assert.equal(written, true); + assert.equal( + JSON.parse(await readFile(modelAt(1), 'utf8')).render_type, + 'minecraft:translucent', + 'the entry’s own slot must pick up the new render layer', + ); + assert.equal( + await readFile(modelAt(0), 'utf8'), + untouchedBefore, + 'no other slot may be rewritten', + ); + + // Both copies of a block model stay in step — the pair is what lets one blockstate + // serve versions either side of 1.13. + assert.equal( + await readFile(modelAt(1), 'utf8'), + await readFile( + path.join(root, 'pack/assets/ella/models/block/block/slot_001.json'), + 'utf8', + ), + ); +}); + +test('an unbound entry has no slot to write', async () => { + const root = path.join(workspace, 'targeted-unbound'); + const project = { ...emptyProject('P', 'proj'), slotPool: { block: 1, item: 1 } }; + await writeSlotNamespace(root, project, 34); + + const entry = { + id: 'floating', + kind: 'block' as const, + slot: null, + displayName: { en: 'Floating' }, + settings: {}, + model: { source: 'json' as const, path: 'x.json', output: 'x.json' }, + }; + + assert.equal(await writeEntrySlot(root, project, entry), false); +}); + // --------------------------------------------------------------------------- // Identifiers // --------------------------------------------------------------------------- @@ -307,17 +388,41 @@ test('reports an exhausted pool instead of returning a bad slot', () => { // --------------------------------------------------------------------------- test('creates a project on disk and reads it back', async () => { - const { project, root } = await createProject('My Project', 'myproject', ['1.12.2']); + const { project, root } = await createProject('My Project', 'myproject', '1.12.2'); assert.equal(project.namespace, 'myproject'); const reloaded = await loadProject(root); assert.equal(reloaded.name, 'My Project'); - assert.deepEqual(reloaded.targetVersions, ['1.12.2']); + assert.equal(reloaded.targetVersion, '1.12.2'); const mcmeta = JSON.parse(await readFile(path.join(root, 'pack', 'pack.mcmeta'), 'utf8')); assert.ok(mcmeta.pack.pack_format > 0); }); +test('a project written before targetVersion adopts its first old target', async () => { + // `targetVersions` was an array nothing ever read past creation. Its first entry is the + // version the author picked when they created the project, which is exactly what the + // single field now means — discarding it would silently unbind every existing project. + const { root } = await createProject('Legacy', 'legacyproj'); + const manifest = JSON.parse(await readFile(path.join(root, 'project.json'), 'utf8')); + + delete manifest.targetVersion; + manifest.targetVersions = ['1.12.2', '1.21.1']; + await writeFile(path.join(root, 'project.json'), JSON.stringify(manifest, null, 2), 'utf8'); + + assert.equal((await loadProject(root)).targetVersion, '1.12.2'); +}); + +test('a project with no version at all loads unbound rather than failing', async () => { + const { root } = await createProject('Bare', 'bareproj'); + const manifest = JSON.parse(await readFile(path.join(root, 'project.json'), 'utf8')); + + delete manifest.targetVersion; + await writeFile(path.join(root, 'project.json'), JSON.stringify(manifest, null, 2), 'utf8'); + + assert.equal((await loadProject(root)).targetVersion, null); +}); + test('rejects an invalid namespace', async () => { await assert.rejects(createProject('X', 'Bad Namespace'), (error: ProjectError) => { assert.equal(error.code, 'INVALID_NAMESPACE'); @@ -405,7 +510,7 @@ test('deleting an entry keeps its files unless asked otherwise', async () => { }); const modelPath = path.join(root, 'pack/assets/delproj/models/item/gem.json'); - const after = await deleteEntry(root, withEntry, 'gem'); + const { project: after } = await deleteEntry(root, withEntry, 'gem'); assert.equal(after.entries.length, 0); // The author's model is their work; a mis-click must not destroy it. assert.ok(await readFile(modelPath).then(() => true, () => false), 'model still on disk'); diff --git a/launcher/test/session-exit.test.ts b/launcher/test/session-exit.test.ts new file mode 100644 index 0000000..4f0de79 --- /dev/null +++ b/launcher/test/session-exit.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyExit } from '../src/main/session.ts'; + +/* + * Every one of these was a real misreading at some point: killing a process reports a null + * exit code, so a `code !== 0` test treated the Stop button as a crash — an error in the + * log and the crash dialog on screen, for an action the user asked for. + */ + +test('pressing Stop is not a failure', () => { + // Node reports a killed process as (null, 'SIGTERM'), which on its own is + // indistinguishable from being killed by anything else. + assert.deepEqual(classifyExit(true, null, 'SIGTERM'), { kind: 'stopped' }); +}); + +test('a requested stop stays a stop whatever the process reports', () => { + // Windows kills can surface as an exit code rather than a signal. + assert.deepEqual(classifyExit(true, 1, null), { kind: 'stopped' }); + assert.deepEqual(classifyExit(true, 0, null), { kind: 'stopped' }); +}); + +test('quitting from inside the game is silent', () => { + assert.deepEqual(classifyExit(false, 0, null), { kind: 'quit' }); +}); + +test('an outside kill is reported but not diagnosed', () => { + // Nothing faulted, so there is no crash report to collect. + assert.deepEqual(classifyExit(false, null, 'SIGKILL'), { + kind: 'terminated', + signal: 'SIGKILL', + }); + assert.deepEqual(classifyExit(false, null, null), { kind: 'terminated', signal: null }); +}); + +test('a non-zero exit is the only case worth a crash report', () => { + assert.deepEqual(classifyExit(false, 1, null), { kind: 'failed', code: 1 }); + assert.deepEqual(classifyExit(false, 255, null), { kind: 'failed', code: 255 }); +}); diff --git a/launcher/test/textures.test.ts b/launcher/test/textures.test.ts index 9fe28c8..69538e1 100644 --- a/launcher/test/textures.test.ts +++ b/launcher/test/textures.test.ts @@ -52,11 +52,21 @@ test('lists the model texture variables with previews', async () => { const { root, project, entry } = await seed(); const textures = await listTextures(root, project, entry); - assert.equal(textures.length, 1); - assert.equal(textures[0].key, 'all'); - assert.equal(textures[0].reference, 'proj:block/lamp'); - assert.equal(textures[0].exists, true); - assert.equal(textures[0].width, 16); + // `all` and the particle slot. The generated model declares both because it is + // self-contained: inheriting from block/cube_all used to supply `particle: #all` for + // free, and dropping that parent — see shared/model-compat.ts — means saying it here or + // losing break and step particles. + assert.equal(textures.length, 2); + + const all = textures.find((texture) => texture.key === 'all'); + assert.ok(all, 'the drawable variable'); + assert.equal(all.reference, 'proj:block/lamp'); + assert.equal(all.exists, true); + assert.equal(all.width, 16); + + const particle = textures.find((texture) => texture.isParticleSlot); + assert.ok(particle, 'the particle slot'); + assert.equal(particle.reference, 'proj:block/lamp'); }); test('reports which faces use each variable', async () => { diff --git a/launcher/test/undo.test.ts b/launcher/test/undo.test.ts new file mode 100644 index 0000000..68a9090 --- /dev/null +++ b/launcher/test/undo.test.ts @@ -0,0 +1,354 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { UndoRegistry } from '../src/main/undo.ts'; +import { stashFiles, restoreStash, purgeStashes, TRASH_DIR } from '../src/main/trash.ts'; +import { + createProject, + createEntry, + deleteEntry, + restoreEntry, + loadProject, + removeModelParent, + writeModelFile, + textureRelativePath, + ProjectError, +} from '../src/main/project.ts'; +import { removeTexture, restoreTexture, addTexture } from '../src/main/textures.ts'; +import { setDataRoot } from '../src/main/paths.ts'; + +let workspace: string; + +test.before(async () => { + workspace = await mkdtemp(path.join(tmpdir(), 'ella-undo-')); + setDataRoot(workspace); +}); + +test.after(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +const exists = (target: string): Promise => + readFile(target).then(() => true, () => false); + +// --------------------------------------------------------------------------- +// The registry +// --------------------------------------------------------------------------- + +test('an offer runs the inverse it was registered with', async () => { + const registry = new UndoRegistry(); + let ran = false; + + const offer = registry.offer('entry.deletedDone', { id: 'x' }, async () => { + ran = true; + }); + + await registry.run(offer.token); + assert.equal(ran, true); +}); + +test('an offer carries what the notification needs to say', async () => { + const registry = new UndoRegistry(); + const offer = registry.offer('entry.deletedDone', { id: 'ruby' }, async () => {}); + + assert.equal(offer.messageKey, 'entry.deletedDone'); + assert.deepEqual(offer.values, { id: 'ruby' }); + assert.ok(offer.token.length > 0); +}); + +test('an offer is one-shot', async () => { + // Pressing Undo twice must not apply the inverse twice: restoring a deleted entry a + // second time would fail on the duplicate, and the second error is the confusing one. + const registry = new UndoRegistry(); + let runs = 0; + const offer = registry.offer('k', {}, async () => { + runs++; + }); + + await registry.run(offer.token); + await assert.rejects(registry.run(offer.token), (error: Error & { code: string }) => { + assert.equal(error.code, 'UNDO_EXPIRED'); + return true; + }); + assert.equal(runs, 1); +}); + +test('a failed inverse is not offered again', async () => { + // It has already done whatever part of its work it managed; running it again compounds + // that rather than retrying it. + const registry = new UndoRegistry(); + const offer = registry.offer('k', {}, async () => { + throw new Error('nope'); + }); + + await assert.rejects(registry.run(offer.token), /nope/); + await assert.rejects(registry.run(offer.token), /no longer be undone/); +}); + +test('two offers do not collide', async () => { + const registry = new UndoRegistry(); + const order: string[] = []; + + const first = registry.offer('a', {}, async () => void order.push('a')); + const second = registry.offer('b', {}, async () => void order.push('b')); + + await registry.run(second.token); + await registry.run(first.token); + assert.deepEqual(order, ['b', 'a']); +}); + +test('only the last few offers stay live', async () => { + // They hold closures over a project state that moves on; keeping them all would let an + // undo from ten actions ago apply to something it no longer describes. + const registry = new UndoRegistry(); + const offers = Array.from({ length: 12 }, (_, index) => + registry.offer(String(index), {}, async () => {}), + ); + + await assert.rejects(registry.run(offers[0].token), /no longer be undone/); + await registry.run(offers[11].token); +}); + +test('clearing drops every offer', async () => { + const registry = new UndoRegistry(); + const offer = registry.offer('k', {}, async () => {}); + + registry.clear(); + await assert.rejects(registry.run(offer.token), /no longer be undone/); +}); + +// --------------------------------------------------------------------------- +// The stash +// --------------------------------------------------------------------------- + +async function stashWorkspace(name: string): Promise { + const root = path.join(workspace, name); + await mkdir(path.join(root, 'pack', 'assets', 'x'), { recursive: true }); + await writeFile(path.join(root, 'pack', 'assets', 'x', 'a.json'), '{"a":1}', 'utf8'); + await writeFile(path.join(root, 'pack', 'assets', 'x', 'b.png'), 'png', 'utf8'); + return root; +} + +test('stashing moves files out of the project and back again', async () => { + const root = await stashWorkspace('stash-round-trip'); + const paths = ['pack/assets/x/a.json', 'pack/assets/x/b.png']; + + assert.deepEqual(await stashFiles(root, 'entry-a', paths), paths); + assert.equal(await exists(path.join(root, 'pack/assets/x/a.json')), false); + + assert.deepEqual((await restoreStash(root, 'entry-a')).sort(), paths); + assert.equal(await readFile(path.join(root, 'pack/assets/x/a.json'), 'utf8'), '{"a":1}'); +}); + +test('stashing skips a file that is not there', async () => { + // An entry may never have had a Blockbench source, and refusing to delete it over that + // would be absurd. + const root = await stashWorkspace('stash-missing'); + const moved = await stashFiles(root, 'entry-a', ['pack/assets/x/a.json', 'sources/a.bbmodel']); + + assert.deepEqual(moved, ['pack/assets/x/a.json']); +}); + +test('restoring never overwrites a file that came back on its own', async () => { + // Recreating an entry with the same id after deleting it is the case: the new file is + // the author's current work, and an undo of the old delete must not bury it. + const root = await stashWorkspace('stash-no-clobber'); + await stashFiles(root, 'entry-a', ['pack/assets/x/a.json']); + await writeFile(path.join(root, 'pack/assets/x/a.json'), 'newer', 'utf8'); + + assert.deepEqual(await restoreStash(root, 'entry-a'), []); + assert.equal(await readFile(path.join(root, 'pack/assets/x/a.json'), 'utf8'), 'newer'); +}); + +test('restoring an empty stash is not an error', async () => { + const root = await stashWorkspace('stash-empty'); + assert.deepEqual(await restoreStash(root, 'never-used'), []); +}); + +test('purging empties the trash', async () => { + const root = await stashWorkspace('stash-purge'); + await stashFiles(root, 'entry-a', ['pack/assets/x/a.json']); + + await purgeStashes(root); + assert.equal(await exists(path.join(root, TRASH_DIR, 'entry-a', 'pack/assets/x/a.json')), false); +}); + +// --------------------------------------------------------------------------- +// Undoing a delete +// --------------------------------------------------------------------------- + +async function projectWithEntries(namespace: string) { + const { project, root } = await createProject('U', namespace, '1.12.2'); + let current = project; + for (const id of ['one', 'two', 'three']) { + current = (await createEntry(root, current, { id, kind: 'block', displayName: { en: id } })) + .project; + } + return { project: current, root }; +} + +test('restoring a deleted entry puts it back where it was', async () => { + // Appending it would reorder a list the author arranged, for no reason other than + // convenience of implementation. + const { project, root } = await projectWithEntries('undodelete'); + const { project: after, entry, index } = await deleteEntry(root, project, 'two'); + + assert.equal(index, 1); + const { project: restored } = await restoreEntry(root, after, entry, index); + + assert.deepEqual(restored.entries.map((candidate) => candidate.id), ['one', 'two', 'three']); + assert.deepEqual((await loadProject(root)).entries.map((c) => c.id), ['one', 'two', 'three']); +}); + +test('restoring brings the files back when the delete took them', async () => { + const { project, root } = await projectWithEntries('undofiles'); + const entry = project.entries.find((candidate) => candidate.id === 'two')!; + const model = path.join(root, ...entry.model.output.split('/')); + const texture = path.join(root, ...textureRelativePath(project, entry).split('/')); + + const deleted = await deleteEntry(root, project, 'two', { deleteFiles: true }); + assert.equal(await exists(model), false); + assert.equal(await exists(texture), false, 'the texture goes too, as the dialog promises'); + + await restoreEntry(root, deleted.project, deleted.entry, deleted.index); + assert.equal(await exists(model), true); + assert.equal(await exists(texture), true); +}); + +test('a restored entry keeps its slot when it is still free', async () => { + const { project, root } = await projectWithEntries('undoslot'); + const deleted = await deleteEntry(root, project, 'two'); + const slot = deleted.entry.slot; + + const { entry } = await restoreEntry(root, deleted.project, deleted.entry, deleted.index); + assert.equal(entry.slot, slot); +}); + +test('a restored entry takes another slot rather than doubling up', async () => { + // Two entries on one slot is a live-editing bug that outlasts the session, and the slot + // is the one part of an entry that is disposable. + const { project, root } = await projectWithEntries('undoslottaken'); + const deleted = await deleteEntry(root, project, 'two'); + const freed = deleted.entry.slot as number; + + const { project: withNew } = await createEntry(root, deleted.project, { + id: 'squatter', + kind: 'block', + displayName: { en: 'Squatter' }, + slot: freed, + }); + + const { entry } = await restoreEntry(root, withNew, deleted.entry, deleted.index); + assert.notEqual(entry.slot, freed); + assert.equal(typeof entry.slot, 'number'); +}); + +test('restoring over an id that came back is refused', async () => { + const { project, root } = await projectWithEntries('undodup'); + const deleted = await deleteEntry(root, project, 'two'); + + const { project: recreated } = await createEntry(root, deleted.project, { + id: 'two', + kind: 'block', + displayName: { en: 'Two again' }, + }); + + await assert.rejects( + restoreEntry(root, recreated, deleted.entry, deleted.index), + (error: ProjectError) => { + assert.equal(error.code, 'DUPLICATE_ID'); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Undoing a texture removal +// --------------------------------------------------------------------------- + +test('restoring a texture variable puts the key back', async () => { + const { project, root } = await createProject('T', 'undotex', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + await addTexture(root, withEntry, entry, 'side'); + + const { removed, textures } = await removeTexture(root, withEntry, entry, 'side'); + assert.equal(textures.some((texture) => texture.key === 'side'), false); + + const after = await restoreTexture(root, withEntry, entry, removed); + const side = after.find((texture) => texture.key === 'side'); + assert.equal(side?.reference, removed.reference); +}); + +test('restoring a texture puts the particle entry back with it', async () => { + // Removing a variable the particle pointed at clears both; an undo that restored only + // one would leave the model half-reverted. + const { project, root } = await createProject('T', 'undopart', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const { removed } = await removeTexture(root, withEntry, entry, 'all'); + assert.equal(removed.wasParticle, true, 'the generated model points particle at #all'); + + const after = await restoreTexture(root, withEntry, entry, removed); + const particle = after.find((texture) => texture.key === 'particle'); + assert.equal(particle?.reference, removed.reference); +}); + +test('a texture whose file was deleted is not restored as a dangling reference', async () => { + // A variable pointing at a missing file stops the whole model loading — worse than the + // removal it was meant to undo. + const { project, root } = await createProject('T', 'undogone', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const { removed } = await removeTexture(root, withEntry, entry, 'all', { deleteFile: true }); + + await assert.rejects( + restoreTexture(root, withEntry, entry, removed), + (error: ProjectError) => { + assert.equal(error.code, 'TEXTURE_FILE_GONE'); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Undoing a model rewrite +// --------------------------------------------------------------------------- + +test('removing a parent hands back the file as it was', async () => { + const { project, root } = await createProject('M', 'undoparent', '1.12.2'); + const { entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const file = path.join(root, ...entry.model.output.split('/')); + const authored = '{\n "parent": "block/cube_all",\n "elements": [ ]\n}\n'; + await writeFile(file, '{"parent":"block/cube_all","elements":[{"from":[0,0,0]}]}', 'utf8'); + const before = await readFile(file, 'utf8'); + + const removed = await removeModelParent(root, entry); + assert.equal(removed?.parent, 'block/cube_all'); + assert.equal(removed?.original, before, 'byte-for-byte, not re-derived'); + assert.equal((await readFile(file, 'utf8')).includes('parent'), false); + + // Undoing is writing that text back, which also restores whatever formatting it had. + await writeModelFile(root, entry, removed!.original); + assert.equal(await readFile(file, 'utf8'), before); + assert.notEqual(before, authored); +}); + +test('a model with nothing to fix reports nothing to undo', async () => { + const { project, root } = await createProject('M', 'undonoop', '1.12.2'); + const { entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + assert.equal(await removeModelParent(root, entry), null); +}); diff --git a/launcher/test/version-compat.test.ts b/launcher/test/version-compat.test.ts new file mode 100644 index 0000000..74b27bf --- /dev/null +++ b/launcher/test/version-compat.test.ts @@ -0,0 +1,334 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + inspectModel, + migrateModel, + retargetVanillaTexture, + vanillaTextureFolders, + versionChangeNotes, + isVersionChange, + type VersionFacts, +} from '../src/shared/version-compat.ts'; +import { planVersionChange, applyVersionChange } from '../src/main/version-change.ts'; +import { createProject, createEntry, loadProject } from '../src/main/project.ts'; +import { setDataRoot } from '../src/main/paths.ts'; + +let workspace: string; + +test.before(async () => { + workspace = await mkdtemp(path.join(tmpdir(), 'ella-compat-')); + setDataRoot(workspace); +}); + +test.after(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Vanilla texture folders +// --------------------------------------------------------------------------- + +test('vanilla texture folders follow the 1.13 rename', () => { + assert.deepEqual(vanillaTextureFolders('1.12.2'), { block: 'blocks', item: 'items' }); + assert.deepEqual(vanillaTextureFolders('1.13'), { block: 'block', item: 'item' }); + assert.deepEqual(vanillaTextureFolders('1.21.1'), { block: 'block', item: 'item' }); +}); + +test('a modded version id is read as the release it is built on', () => { + // `1.12.2-forge-14.23.5.2859` does not parse as a version, and an unparseable id sorts + // after every release — which would answer "yes" to "is this at least 1.13?". + assert.deepEqual(vanillaTextureFolders('1.12.2-forge-14.23.5.2859'), { + block: 'blocks', + item: 'items', + }); +}); + +test('retargets a vanilla texture across the rename, in both directions', () => { + assert.equal(retargetVanillaTexture('minecraft:blocks/stone', '1.21.1'), 'minecraft:block/stone'); + assert.equal(retargetVanillaTexture('minecraft:block/stone', '1.12.2'), 'minecraft:blocks/stone'); + assert.equal(retargetVanillaTexture('minecraft:items/apple', '1.21.1'), 'minecraft:item/apple'); + assert.equal(retargetVanillaTexture('minecraft:item/apple', '1.12.2'), 'minecraft:items/apple'); +}); + +test('an unqualified reference is vanilla, and keeps its spelling', () => { + // Minecraft resolves a bare path against `minecraft`, so this is the same mistake — and + // adding a namespace the author did not write would be a second, unrelated change. + assert.equal(retargetVanillaTexture('blocks/stone', '1.21.1'), 'block/stone'); +}); + +test('leaves a reference that already suits the version alone', () => { + assert.equal(retargetVanillaTexture('minecraft:block/stone', '1.21.1'), null); + assert.equal(retargetVanillaTexture('minecraft:blocks/stone', '1.12.2'), null); +}); + +test("never touches a pack's own textures", () => { + // A resource pack may lay its own namespace out however it likes: the reference *is* the + // path. Rewriting these would break the one spelling that works on every version. + assert.equal(retargetVanillaTexture('myproject:block/ruby', '1.12.2'), null); + assert.equal(retargetVanillaTexture('myproject:blocks/ruby', '1.21.1'), null); +}); + +test('leaves texture variables alone', () => { + assert.equal(retargetVanillaTexture('#all', '1.12.2'), null); +}); + +test('ignores vanilla paths outside the renamed folders', () => { + assert.equal(retargetVanillaTexture('minecraft:entity/creeper/creeper', '1.12.2'), null); + assert.equal(retargetVanillaTexture('minecraft:stone', '1.21.1'), null); +}); + +// --------------------------------------------------------------------------- +// Inspection +// --------------------------------------------------------------------------- + +const CUBE_WITH_PARENT = { + parent: 'block/cube_all', + textures: { all: 'myproject:block/ruby' }, + elements: [{ from: [0, 0, 0], to: [16, 16, 16], faces: {} }], +}; + +test('a parent over own geometry is only a problem below 1.9', () => { + assert.deepEqual( + inspectModel(CUBE_WITH_PARENT, '1.8.9').map((issue) => issue.id), + ['parentOverridesGeometry'], + ); + assert.deepEqual(inspectModel(CUBE_WITH_PARENT, '1.12.2'), []); +}); + +test('a parent with no geometry of its own is never a problem', () => { + // The ordinary `cube_all` case, and Ella's own slot redirects. + const model = { parent: 'block/cube_all', textures: { all: 'x' } }; + assert.deepEqual(inspectModel(model, '1.8.9'), []); +}); + +test('reports a vanilla texture written for the other era', () => { + const model = { textures: { all: 'minecraft:blocks/stone' } }; + const [issue] = inspectModel(model, '1.21.1'); + + assert.equal(issue.id, 'vanillaTextureFolder'); + assert.equal(issue.fixable, true); + assert.equal(issue.detail.reference, 'minecraft:blocks/stone'); + assert.equal(issue.detail.expected, 'minecraft:block/stone'); +}); + +test('several stale references in one model are one finding', () => { + // They are the same mistake made once and fixed in one pass; a row each would bury the + // rest of the report. + const model = { + textures: { a: 'minecraft:blocks/stone', b: 'minecraft:blocks/dirt', c: '#a' }, + }; + const issues = inspectModel(model, '1.21.1'); + + assert.equal(issues.length, 1); + assert.equal(issues[0].detail.count, 2); +}); + +test('a model that is right for the version has nothing to report', () => { + const model = { textures: { all: 'myproject:block/ruby' }, elements: [] }; + assert.deepEqual(inspectModel(model, '1.8.9'), []); + assert.deepEqual(inspectModel(model, '1.21.1'), []); +}); + +test('rejects things that are not models rather than throwing', () => { + assert.deepEqual(inspectModel(null, '1.12.2'), []); + assert.deepEqual(inspectModel('nope', '1.12.2'), []); +}); + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +test('migrating to 1.8 drops the parent that would override the geometry', () => { + const { model, applied } = migrateModel(CUBE_WITH_PARENT, '1.8.9'); + + assert.deepEqual(applied, ['parentOverridesGeometry']); + assert.equal('parent' in model, false); + assert.deepEqual(model.elements, CUBE_WITH_PARENT.elements, 'the geometry survives'); +}); + +test('migrating rewrites vanilla texture references', () => { + const source = { textures: { all: 'minecraft:blocks/stone', own: 'myproject:block/ruby' } }; + const { model } = migrateModel(source, '1.21.1'); + + assert.deepEqual(model.textures, { + all: 'minecraft:block/stone', + own: 'myproject:block/ruby', + }); +}); + +test('migration leaves the input untouched', () => { + const source = { parent: 'block/cube_all', elements: [{ from: [0, 0, 0], to: [1, 1, 1] }] }; + migrateModel(source, '1.8.9'); + assert.equal(source.parent, 'block/cube_all'); +}); + +test('migrating twice changes nothing the second time', () => { + // The dialog can be answered more than once over a project's life; a migration that + // drifted on each pass would rewrite files for no reason and dirty every diff. + const first = migrateModel({ textures: { all: 'minecraft:blocks/stone' } }, '1.21.1'); + const second = migrateModel(first.model, '1.21.1'); + + assert.deepEqual(second.applied, []); + assert.deepEqual(second.model, first.model); +}); + +test('a model can need both fixes at once', () => { + const source = { + parent: 'block/cube_all', + textures: { all: 'minecraft:block/stone' }, + elements: [{ from: [0, 0, 0], to: [16, 16, 16] }], + }; + const { applied } = migrateModel(source, '1.8.9'); + + assert.deepEqual(applied.sort(), ['parentOverridesGeometry', 'vanillaTextureFolder']); +}); + +// --------------------------------------------------------------------------- +// Consequences beyond the files +// --------------------------------------------------------------------------- + +const facts = (over: Partial): VersionFacts => ({ + id: '1.12.2', + installed: true, + adapterStatus: 'built', + javaAvailable: true, + requiredJava: 8, + ...over, +}); + +test('a version with no adapter is called out', () => { + const notes = versionChangeNotes(null, facts({ id: '1.19.2', adapterStatus: null })); + assert.deepEqual(notes.map((note) => note.id), ['noAdapter']); +}); + +test('losing live editing is its own note', () => { + // Told apart from "this version cannot live-edit" on purpose: it matters far more to + // someone who had it a moment ago. + const notes = versionChangeNotes( + facts({ id: '1.12.2' }), + facts({ id: '1.19.2', adapterStatus: null }), + ); + assert.deepEqual(notes.map((note) => note.id), ['noAdapter', 'losesLiveEditing']); +}); + +test('moving between two live-editing versions says nothing', () => { + assert.deepEqual(versionChangeNotes(facts({}), facts({ id: '1.21.1' })), []); +}); + +test('a missing Java runtime is reported with the version it is for', () => { + const notes = versionChangeNotes(null, facts({ id: '1.21.1', javaAvailable: false, requiredJava: 21 })); + assert.deepEqual(notes.map((note) => note.id), ['javaMissing']); + assert.equal(notes[0].detail.java, 21); +}); + +test('an unbound project is not a version change', () => { + assert.equal(isVersionChange(null, '1.12.2'), false); + assert.equal(isVersionChange('1.12.2', '1.12.2'), false); + assert.equal(isVersionChange('1.12.2', '1.8.9'), true); +}); + +// --------------------------------------------------------------------------- +// Against a project on disk +// --------------------------------------------------------------------------- + +async function projectWithModel(namespace: string, model: unknown) { + const { project, root } = await createProject('P', namespace, '1.12.2'); + const created = await createEntry(root, project, { + id: 'ruby', + kind: 'block', + displayName: { en: 'Ruby' }, + }); + + const file = path.join(root, ...created.entry.model.output.split('/')); + await writeFile(file, JSON.stringify(model, null, 2), 'utf8'); + + return { project: created.project, root, file }; +} + +test('planning reads the models as they are on disk', async () => { + const { project, root } = await projectWithModel('planned', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const plan = await planVersionChange(root, project, '1.21.1'); + + assert.equal(plan.from, '1.12.2'); + assert.equal(plan.needsConfirmation, true); + assert.equal(plan.fixable, 1); + assert.deepEqual(plan.findings.map((finding) => finding.entryId), ['ruby']); +}); + +test('planning a launch on the project\'s own version asks for nothing', async () => { + const { project, root } = await projectWithModel('samever', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const plan = await planVersionChange(root, project, '1.12.2'); + assert.equal(plan.needsConfirmation, false); + assert.deepEqual(plan.findings, []); +}); + +test('a missing model file is not reported as a version problem', async () => { + // validateForExport already reports it properly; a second diagnosis for the same file + // would only compete with the first. + const { project, root, file } = await projectWithModel('nomodel', { textures: {} }); + await rm(file); + + const plan = await planVersionChange(root, project, '1.21.1'); + assert.deepEqual(plan.findings, []); +}); + +test('applying without migrating rebinds the project and leaves the files alone', async () => { + const { project, root, file } = await projectWithModel('nomigrate', { + textures: { all: 'minecraft:blocks/stone' }, + }); + const before = await readFile(file, 'utf8'); + + const result = await applyVersionChange(root, project, '1.21.1', { migrate: false }); + + assert.deepEqual(result.migrated, []); + assert.equal(result.project.targetVersion, '1.21.1'); + assert.equal((await loadProject(root)).targetVersion, '1.21.1'); + assert.equal(await readFile(file, 'utf8'), before); +}); + +test('applying with migration rewrites the models and rebinds', async () => { + const { project, root, file } = await projectWithModel('migrate', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const result = await applyVersionChange(root, project, '1.21.1', { migrate: true }); + + assert.deepEqual(result.migrated, ['ruby']); + const written = JSON.parse(await readFile(file, 'utf8')) as { textures: Record }; + assert.equal(written.textures.all, 'minecraft:block/stone'); + + // And the project is now clean for the version it is bound to. + const plan = await planVersionChange(root, result.project, '1.21.1'); + assert.deepEqual(plan.findings, []); +}); + +test('a migrated model keeps the formatting Blockbench writes', async () => { + // These are the author's files; the next save has to see something it recognises rather + // than a diff of the whole document. + const { project, root, file } = await projectWithModel('formatting', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + await applyVersionChange(root, project, '1.21.1', { migrate: true }); + const written = await readFile(file, 'utf8'); + + assert.match(written, /^\{\n {2}"textures"/, 'two-space indentation'); + assert.match(written, /\n$/, 'trailing newline'); +}); + +test('a project bound to nothing adopts the version it is applied to', async () => { + const { project, root } = await createProject('Fresh', 'freshproj'); + assert.equal(project.targetVersion, null); + + const result = await applyVersionChange(root, project, '1.12.2', { migrate: false }); + assert.equal(result.project.targetVersion, '1.12.2'); +}); diff --git a/launcher/test/version.test.ts b/launcher/test/version.test.ts index 7ddaec3..58ac227 100644 --- a/launcher/test/version.test.ts +++ b/launcher/test/version.test.ts @@ -87,15 +87,27 @@ test('picks the Java runtime each version actually needs', () => { }); test('routes a version only to an adapter that is actually built', () => { + assert.equal(adapterFor('1.8.9'), 'forge-1.8.9'); assert.equal(adapterFor('1.12.2'), 'forge-1.12.2'); assert.equal(adapterFor('1.21.1'), 'forge-modern'); // Designed-for but unbuilt buckets must not be offered as working. - assert.equal(adapterFor('1.8.9'), null); assert.equal(adapterFor('1.16.5'), null); assert.equal(adapterFor('1.20.1'), null); }); +test('the 1.8 adapter claims only the two versions its mappings cover', () => { + // 1.8.8 and 1.8.9 share every obfuscated name the adapter overrides, so one jar serves + // both. 1.8 through 1.8.7 do not, and offering the jar there would bind the overrides + // to whatever those names happen to mean instead — a mod that loads and misbehaves, + // which is worse than one that is simply unavailable. + assert.equal(adapterFor('1.8.8'), 'forge-1.8.9'); + assert.equal(adapterFor('1.8.9'), 'forge-1.8.9'); + assert.equal(adapterFor('1.8'), null); + assert.equal(adapterFor('1.8.7'), null); + assert.equal(adapterFor('1.9'), null); +}); + test('a planned bucket is still reported as covering its range', () => { // The UI distinguishes "no adapter will ever apply" from "not built yet". assert.equal(adapterCoverageFor('1.16.5')?.id, 'forge-mid'); diff --git a/launcher/test/workflow.test.ts b/launcher/test/workflow.test.ts new file mode 100644 index 0000000..b929a3b --- /dev/null +++ b/launcher/test/workflow.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + workflowSteps, + isSetupComplete, + completedCount, + currentStep, + canLiveEdit, + type WorkflowFacts, +} from '../src/shared/workflow.ts'; +import { translate, LOCALES } from '../src/shared/i18n.ts'; + +const NOTHING: WorkflowFacts = { + installedVersions: 0, + liveEditingVersions: 0, + hasProject: false, + entryCount: 0, + blockbenchFound: false, + connected: false, +}; + +const EVERYTHING: WorkflowFacts = { + installedVersions: 2, + liveEditingVersions: 1, + hasProject: true, + entryCount: 3, + blockbenchFound: true, + connected: true, +}; + +test('a fresh install has nothing done and starts at the first step', () => { + const steps = workflowSteps(NOTHING); + assert.equal(completedCount(steps), 0); + assert.equal(currentStep(steps)?.id, 'version'); + assert.equal(isSetupComplete(steps), false); +}); + +test('a fully set up session has no current step', () => { + const steps = workflowSteps(EVERYTHING); + assert.equal(isSetupComplete(steps), true); + assert.equal(currentStep(steps), null); +}); + +test('exactly one step is current, however many are outstanding', () => { + const steps = workflowSteps({ ...NOTHING, installedVersions: 1, blockbenchFound: true }); + assert.equal(steps.filter((step) => step.current).length, 1); + // Blockbench being found already does not pull it in front of the project step. + assert.equal(currentStep(steps)?.id, 'project'); +}); + +test('a later step can be done while an earlier one is not', () => { + const steps = workflowSteps({ ...NOTHING, blockbenchFound: true }); + const blockbench = steps.find((step) => step.id === 'blockbench'); + assert.equal(blockbench?.done, true); + assert.equal(blockbench?.current, false); +}); + +test('closing the project un-does the steps that depended on it', () => { + // Entry count is stale for a moment after a project closes; the step must not claim + // an entry exists when there is no project to hold it. + const steps = workflowSteps({ ...EVERYTHING, hasProject: false }); + assert.equal(steps.find((step) => step.id === 'entry')?.done, false); +}); + +test('the game disconnecting reopens the launch step', () => { + const steps = workflowSteps({ ...EVERYTHING, connected: false }); + assert.equal(isSetupComplete(steps), false); + assert.equal(currentStep(steps)?.id, 'launch'); +}); + +test('installed versions without a built adapter cannot live-edit', () => { + assert.equal(canLiveEdit({ ...NOTHING, installedVersions: 3 }), false); + assert.equal(canLiveEdit({ ...NOTHING, installedVersions: 3, liveEditingVersions: 1 }), true); +}); + +test('every step has a title, a reason and an action in both locales', () => { + for (const step of workflowSteps(NOTHING)) { + for (const locale of LOCALES) { + for (const suffix of ['title', 'why', 'action']) { + const key = `guide.${step.id}.${suffix}`; + assert.notEqual(translate(locale, key), key, `${key} is missing in ${locale}`); + } + } + } +}); diff --git a/launcher/tsconfig.node.tsbuildinfo b/launcher/tsconfig.node.tsbuildinfo deleted file mode 100644 index 5ae20be..0000000 --- a/launcher/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./src/shared/version.ts","./src/main/paths.ts","./src/main/adapters.ts","./src/main/blockbench-plugin.ts","./src/shared/protocol.ts","./locales/en.json","./locales/fr.json","./src/shared/i18n.ts","./src/main/config.ts","./src/main/blockbench.ts","./src/main/minecraft/download.ts","./src/main/java-runtime.ts","./src/main/minecraft/forge.ts","./src/main/minecraft/types.ts","./src/main/minecraft/rules.ts","./src/main/minecraft/libraries.ts","./src/main/minecraft/manifest.ts","./src/main/minecraft/launch.ts","./src/main/diagnostics.ts","./src/main/ella-server.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/adm-zip/util.d.ts","./node_modules/@types/adm-zip/index.d.ts","./src/shared/project.ts","./src/main/pack.ts","./src/main/export.ts","./node_modules/electron/electron.d.ts","./src/shared/ipc.ts","./src/main/minecraft/install.ts","./src/main/minecraft/uninstall.ts","./src/shared/settings-schema.ts","./src/main/project.ts","./src/main/session.ts","./src/main/textures.ts","./src/main/index.ts","./src/preload/index.ts","./src/shared/model-preview.ts","./test/diagnostics.test.ts","./test/ella-server.test.ts","./test/export.test.ts","./test/i18n.test.ts","./test/java-runtime.test.ts","./test/minecraft.test.ts","./test/model-preview.test.ts","./test/pack-project.test.ts","./test/project-delete.test.ts","./test/rename.test.ts","./test/settings-schema.test.ts","./test/texture.test.ts","./test/textures.test.ts","./test/uninstall.test.ts","./test/version.test.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/electron-vite/dist/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./electron.vite.config.ts"],"fileIdsList":[[87,137,154,155,159,251,258],[87,137,154,155],[87,137,154,155,252],[87,137,149,154,155,187,188],[87,137,154,155,252,253,254,255,256],[87,137,154,155,252,254],[87,134,135,137,154,155],[87,136,137,154,155],[137,154,155],[87,137,142,154,155,172],[87,137,138,143,148,154,155,157,169,180],[87,137,138,139,148,154,155,157],[82,83,84,87,137,154,155],[87,137,140,154,155,181],[87,137,141,142,149,154,155,158],[87,137,142,154,155,169,177],[87,137,143,145,148,154,155,157],[87,136,137,144,154,155],[87,137,145,146,154,155],[87,137,147,148,154,155],[87,136,137,148,154,155],[87,137,148,149,150,154,155,169,180],[87,137,148,149,150,154,155,164,169,172],[87,129,137,145,148,151,154,155,157,169,180],[87,137,148,149,151,152,154,155,157,169,177,180],[87,137,151,153,154,155,169,177,180],[85,86,87,88,89,90,91,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186],[87,137,148,154,155],[87,137,154,155,156,180],[87,137,145,148,154,155,157,169],[87,137,154,155,158],[87,137,154,155,159],[87,136,137,154,155,160],[87,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186],[87,137,154,155,162],[87,137,154,155,163],[87,137,148,154,155,164,165],[87,137,154,155,164,166,181,183],[87,137,149,154,155],[87,137,148,154,155,169,170,172],[87,137,154,155,171,172],[87,137,154,155,169,170],[87,137,154,155,172],[87,137,154,155,173],[87,134,137,154,155,169,174,180],[87,137,148,154,155,175,176],[87,137,154,155,175,176],[87,137,142,154,155,157,169,177],[87,137,154,155,178],[87,137,154,155,157,179],[87,137,151,154,155,163,180],[87,137,142,154,155,181],[87,137,154,155,169,182],[87,137,154,155,156,183],[87,137,154,155,184],[87,129,137,154,155],[87,129,137,148,150,154,155,160,169,172,180,182,183,185],[87,137,154,155,169,186],[87,137,154,155,250,257],[87,137,154,155,187,250],[87,137,148,149,154,155,187],[87,137,154,155,242],[87,137,154,155,240,242],[87,137,154,155,231,239,240,241,243,245],[87,137,154,155,229],[87,137,154,155,232,237,242,245],[87,137,154,155,228,245],[87,137,154,155,232,233,236,237,238,245],[87,137,154,155,232,233,234,236,237,245],[87,137,154,155,229,230,231,232,233,237,238,239,241,242,243,245],[87,137,154,155,245],[87,137,154,155,227,229,230,231,232,233,234,236,237,238,239,240,241,242,243,244],[87,137,154,155,227,245],[87,137,154,155,232,234,235,237,238,245],[87,137,154,155,236,245],[87,137,154,155,237,238,242,245],[87,137,154,155,230,240],[87,137,154,155,220,249],[87,137,154,155,219,220],[87,101,105,137,154,155,180],[87,101,137,154,155,169,180],[87,96,137,154,155],[87,98,101,137,154,155,177,180],[87,137,154,155,157,177],[87,137,154,155,187],[87,96,137,154,155,187],[87,98,101,137,154,155,157,180],[87,93,94,97,100,137,148,154,155,169,180],[87,101,108,137,154,155],[87,93,99,137,154,155],[87,101,122,123,137,154,155],[87,97,101,137,154,155,172,180,187],[87,122,137,154,155,187],[87,95,96,137,154,155,187],[87,101,137,154,155],[87,95,96,97,98,99,100,101,102,103,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,123,124,125,126,127,128,137,154,155],[87,101,116,137,154,155],[87,101,108,109,137,154,155],[87,99,101,109,110,137,154,155],[87,100,137,154,155],[87,93,96,101,137,154,155],[87,101,105,109,110,137,154,155],[87,105,137,154,155],[87,99,101,104,137,154,155,180],[87,93,98,101,108,137,154,155],[87,137,154,155,169],[87,96,101,122,137,154,155,185,187],[87,137,148,149,151,152,153,154,155,157,169,177,180,186,187,220,221,222,223,224,225,226,246,247,248,249],[87,137,154,155,222,223,224,225],[87,137,154,155,222,223,224],[87,137,154,155,222],[87,137,154,155,223],[87,137,154,155,220],[62,63,87,137,142,150,154,155,159,180],[87,137,150,154,155,158,159,180],[70,87,137,138,148,149,150,154,155,159],[63,66,69,87,137,149,150,154,155,158,159,181],[62,63,73,74,79,87,137,150,154,155,158,159],[66,87,137,142,148,154,155,157],[87,137,150,154,155,159,189,190,191],[62,63,64,65,70,73,74,78,80,87,137,154,155,159,180,191,192,193,194,195,196,198,199,200],[62,87,137,138,149,150,154,155,158,159,181],[87,137,142,149,150,154,155,159,169,170],[62,63,72,73,87,137,138,150,154,155,159],[63,72,75,77,78,87,137,150,154,155,159,189],[63,66,73,75,76,77,78,87,137,138,142,154,155,159],[75,76,87,137,154,155,159],[62,63,72,75,77,87,137,150,154,155,159],[75,87,137,154,155,158],[63,74,87,137,150,154,155,159],[66,87,137,150,154,155,159,186,190],[87,137,150,154,155,158,159],[63,66,87,137,142,150,154,155,159,190,191,197],[64,66,70,71,74,79,80,81,87,137,138,142,148,154,155,159,190,191,198],[87,137,150,154,155,159,190,191,198],[87,137,154,155,193,194],[67,68,87,137,154,155],[66,69,87,137,154,155,190],[66,87,137,154,155],[63,80,87,135,137,150,154,155,158,159,174],[66,81,87,135,137,148,154,155,157,174],[87,135,137,150,154,155,158,159,174,189,190,192,197],[69,87,135,137,154,155,174,197],[73,87,135,137,154,155,174],[75,76,77,79,87,135,137,154,155,159,174],[87,135,137,154,155,174,203],[62,63,87,135,137,150,154,155,158,159,174,186,190,191,198],[63,87,135,137,150,154,155,158,159,174,198],[63,87,135,137,150,154,155,158,159,174,190,198],[66,87,135,137,154,155,174,197],[63,87,135,137,150,154,155,158,159,174,191,198],[63,87,135,137,150,154,155,158,159,174,190,191,198,200],[63,64,87,135,137,150,154,155,158,159,174,196],[62,87,135,137,154,155,174]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d738b4fe64ce86e7f2b349b816af4c33bc1135c684e044da17a0939a4236a11","signature":"09dec37ae00390b067901a933cfdd2f4311c1f55f4d496fa77b77faee43a09eb"},"fce5ccc4e0e8435649dba2e937c899cbd7d016eb3db0ce8c93f9be664bd6c757","7d2890b169a0b70e6088a7450c89e266e6c750252a33e500d2faefa75a6d047f",{"version":"9f3ce99e3ffebfd8d89d147fa22efe1fb5f6130e458ef5c9143cce74027806a5","signature":"05c93ad6c450c594ee711e5bbcfc65e84173af5a202db32c5e732de36068f002"},{"version":"7f93ec79df97ce06a9f7e2d87091f4ff14f7fb6a882375abbca1e488bae5c2c0","signature":"c32dc91adfdb43dc74adf80238b1e73ff47a3cebe4cff0631b18953a37a9a684"},{"version":"0e25dd83bfa28c7f5fb9a961505623c1d17bc35569b087878471f83e5556e3e0","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},{"version":"980796b30a30050db7b81611ecff08804c466058d15d2bef1134b1e1422d2ef8","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},"2fcc52ac9003375e35b45f3c4e05912549ccf7a94bdf28ebf81fb9658afcf376","05fd9f833fd1f09d57518ea1e141c9eea930228a13d17fbf1273a638b971ee6b","e8e073b22d9b564c575fcf9adc1c7df24d920dd19e8dfa598ed44ff2fec08f7b",{"version":"eea92a9a8245e5850df12ac2ea50e89e8b7db1f55725687d1f633f5713439764","signature":"9136c5188e48b4a50ce996953dcf08a38ed782159781a8184b8bca66e3e2a639"},"cb7766b043983f7cb1c942334aaa353058ef63b543de9afa02fe57059731edf1","3d1f235b49c982dcff2ed742bfb2711cee6feb085248f9e370822ecf3471fb22",{"version":"1e7852d5ae1ef5fd555e057c9d3a2992b6f7985f8ae432f172aff80eba6ab4ae","signature":"2d5e88f34e4648099405c6d86ed53127f04151a42cd3bfe1ff515a7facab89a8"},"237373264d871eac5c49281fc0b53eee1c4f2380a23957d9df3b065f353c284b",{"version":"55a29d4ae913aaad889b7dc6dd9cc62620e69a08bbc9ff78cfe3c984163dc878","signature":"d3b76565851c3b9cac396f7a068de96ec24822cb6c1db2c96a1428aafa1d02a4"},"28414d91c0df47a8a7115fd2835762359b64490631b2411f2f38f9c41500e151","ccfbc05766ec18fa552cfbc6ec797e5b02d8469928f6e0ad6c8c917077b9c102","a286879fd6abc9aefda7464c4d04b2ee32b632c641ffef29ee759f40c1c739b8","71b7f88c1df971b8e45c0193186d53621c7cf45839ba886b626519e1d1cc70e1",{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"78dbea00e90d2df8ea3dbef0cc379d95b8be9b71cd6bde4c28728f306811803b","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e1e46d0a9837ee058c100501080c920fa98081ea3956af0374308ba6f22a33e","impliedFormat":1},{"version":"272ca407e0c9068bdc5152552d876e68037ceae3de62e529306403e973dec8e1","impliedFormat":1},{"version":"fa7834c715d5357e4540cee40ce96c3250ddb67a7b879a6b7fa0e86d6696f121","impliedFormat":1},{"version":"22dfb07a7ab15b66ac043829056fe70124844636ae719551812ac631ba04985b","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"0cb167c371eaa8c869f8a7656a7296f2e4fae43b4d8b803a680236b24794e5f9","impliedFormat":1},{"version":"0a839dba0287cc0481ad4beedd48a1c64acf1e212ae865d1315f7007ca215161","impliedFormat":1},{"version":"38dc4655376cd1a4bd6bb3763d92949233e33d38d3dd3cbea7bbf218175a38ef","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"d61e0a64cd175208ac0b83670151a9a6b5916f0d1ffcdc5c29c90b1cebfc5045","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"48a679952eefe4cb776d5a0e1ccba2d3eb53b57448bbb7abc1fcebcbd5440188","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2d14da6ecb49bf828d83948765ec2d3a579d476bbb9645e749610baa6ec880ca","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"0aef708fb4c7a6b915e8305cbfac40cd207b032dbaabe9a01889a5fff3254681","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"ad9bdafb4e7abf14cc53ce7970486a84c87831e62891e5dfe798ddcd55e84701","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"71d3ae6a5e73ca4130762560425e00984ebaff64d5353a3333d1bb7eb86ef336","impliedFormat":1},{"version":"9063bd95bf4fe06fe71b4f1abd4dce7d41b684d9036ff4afae6e079eb252b19e","impliedFormat":1},{"version":"6fea400d16717040fe04edaf9833d7fe4680755a1e7ed03c87e961c5cde3b8a0","impliedFormat":1},{"version":"0230fd5c0a569a1ab873df98051bac5e163e06adb8669f8713e794afd48f0e02","signature":"a1b601110bbab5f5def3fd39dde2ec65b272490321549295db19d2e3e6652857"},"35c36b8fe740993b80cee7d5be460ec7b8471d94dedaf58bdbd50390d8337221","87a547302ce6719e13cb0a37c248133ba428d98a33192bd849b1d78cf65deb7b",{"version":"e559d0282980f0f8f2e400dc668514cdb2b0f2dac62f427dbc4df3272eb811fa","affectsGlobalScope":true,"impliedFormat":1},"cdf0d035d910b844495d5dc3464d7cbb65031bc2d70d6b3627be67789fe50901","ab37ada03cac1fab06150b9210d661d902ea540ac84ad1d3070f08001177470f","85a306b977025686111277684835f35148062bd4fb9cb3fe7d7105a80ec2cc39",{"version":"653d38e051e88413fb0adb044643723bf5b73ac17df0edc913bce8516c5b5772","signature":"e293561dcd96b3dffbe7db0d0da7d7185359ddc5070b5d3de58322c683402172"},{"version":"37f62bc9b775750b07d284ce465d5f5ac6ff88ae89d898889381b0115cfa9c85","signature":"2ccf95d83be7d742eb5e503327cf3c010a55673ee437fe39bc0302f25f9c9a29"},"d94ceb32a4f94c1f0b9c8d675960ad16d64959f205816609d813fb63cbfe346a","028781afd602cc9ad8f2bf6c776f47cce0d18164493f741b16949e5cca8dcd25","2a3677f806849c3c1ce947083c40eb94c0617a7651826f34a8d186c46effea70","05a65b70593f086594af8b11b647d4a47084c918710bc087395d449d5fb747bd",{"version":"91e985d8f993207fa3192dbd8fbff4d80065d9cfd2a1dde31b096d8dbb8871c8","signature":"3329a954eb09aaaa16c0e90bc78f8093be20803ecef7be9babf85adb79f4cd66"},"f32275d551e34646e8864efc151d6d1e65ba3ce08f64ab4c8cfb0f9535f815ec","d3b10140feb2fdb03418e97771bf2be3858905aa11c297daf58ae49e1a6ded12","12c8351d85c4197b3a4b91cecc17690e536869ecc550f59195c91726a7b15c60","f4f82fc2f095c4b964001976ec1134c956aac315714327e805e2cc84263b5169","a9ed2e7f363f9848017cc8c3276e60afb2ad5adb659d891631b2499e9a4c1cc3","78a917bb0baa43aa0a4406b6609041622a303cab58530737e409123e41c241d4",{"version":"96d34cd39ea1e022a837a30a068b89e002b1c7756b8de40fae8253a8d1953549","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"008d11592c99e5f8bdf956495be91d6c45012a4624684fe3e5dbe12a1c3459dd","b126b3146876e84bbf5d18189c88f0413627b60d92cd7c4a6ca69250c20ee24a","f8aefcb7746acd86b970c007087f2c63170974d6a8e6f73b4182d04d91b3686e","6ec6a08562cdb219126a4d46da5c30c977594a7ef0cb2b4bf3f4cba209af1509",{"version":"85855bc76fb6cf3bf5a505bebef64a36e6f3636f4b0955fa21e9cd7bf54bc2f8","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"d96ba36a40776bd518131e0abdf49aa6ad43e392471273a21b41acb9ab5a1513","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"57e09995277db76596b3e1f4b006ae2f0d75b9393ff371fd5fb203e90ba2582d",{"version":"f9751dba1d201be79d6e9974a3bb641402eb056ab2e449efac02557e9e4578a5","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"21944c138a48dc23382cb6558b1d4498908faad2104ba7ff390ba8b27c06f3c0","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"bd0e57158bd69732d3199a1d287435d52057e1687c760ae2c21af234b2ec7672","impliedFormat":99},{"version":"69d4b61c408556b97b796782a1110f7e01a03ed80f31741f2c59b722185830ed","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"e6ca59368dce5a594dcde9bbb6ae640d668fa6c28c31639dd2a75b731bb036a2","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},"2820aa21ed06cf37bead1aa22e6b2f05a90a8f69d430d3f63a75bdc10ad0d6a6"],"root":[[62,66],[69,81],[190,192],[194,218],259],"options":{"allowImportingTsExtensions":true,"composite":true,"esModuleInterop":true,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":false,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[259,1],[67,2],[68,2],[254,3],[252,2],[189,4],[188,2],[257,5],[253,3],[255,6],[256,3],[219,2],[134,7],[135,7],[136,8],[87,9],[137,10],[138,11],[139,12],[82,2],[85,13],[83,2],[84,2],[140,14],[141,15],[142,16],[143,17],[144,18],[145,19],[146,19],[147,20],[148,21],[149,22],[150,23],[88,2],[86,2],[151,24],[152,25],[153,26],[187,27],[154,28],[155,2],[156,29],[157,30],[158,31],[159,32],[160,33],[161,34],[162,35],[163,36],[164,37],[165,37],[166,38],[167,2],[168,39],[169,40],[171,41],[170,42],[172,43],[173,44],[174,45],[175,46],[176,47],[177,48],[178,49],[179,50],[180,51],[181,52],[182,53],[183,54],[184,55],[89,2],[90,2],[91,2],[130,56],[131,2],[132,2],[133,43],[185,57],[186,58],[258,59],[92,2],[251,60],[193,61],[226,2],[243,62],[241,63],[242,64],[230,65],[231,63],[238,66],[229,67],[234,68],[244,2],[235,69],[240,70],[246,71],[245,72],[228,73],[236,74],[237,75],[232,76],[239,62],[233,77],[221,78],[220,79],[227,2],[60,2],[61,2],[12,2],[11,2],[2,2],[13,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[3,2],[21,2],[22,2],[4,2],[23,2],[27,2],[24,2],[25,2],[26,2],[28,2],[29,2],[30,2],[5,2],[31,2],[32,2],[33,2],[34,2],[6,2],[38,2],[35,2],[36,2],[37,2],[39,2],[7,2],[40,2],[45,2],[46,2],[41,2],[42,2],[43,2],[44,2],[8,2],[50,2],[47,2],[48,2],[49,2],[51,2],[9,2],[52,2],[53,2],[54,2],[56,2],[55,2],[57,2],[58,2],[10,2],[59,2],[1,2],[108,80],[118,81],[107,80],[128,82],[99,83],[98,84],[127,85],[121,86],[126,87],[101,88],[115,89],[100,90],[124,91],[96,92],[95,85],[125,93],[97,94],[102,95],[103,2],[106,95],[93,2],[129,96],[119,97],[110,98],[111,99],[113,100],[109,101],[112,102],[122,85],[104,103],[105,104],[114,105],[94,106],[117,97],[116,95],[120,2],[123,107],[250,108],[247,109],[225,110],[223,111],[222,2],[224,112],[248,2],[249,113],[64,114],[65,115],[71,116],[70,117],[80,118],[81,119],[192,120],[201,121],[73,122],[72,123],[74,124],[195,125],[79,126],[77,127],[78,128],[76,129],[75,2],[196,130],[191,131],[63,132],[198,133],[199,134],[200,135],[202,136],[69,137],[194,138],[203,2],[190,139],[66,2],[197,139],[62,2],[204,140],[205,141],[206,142],[207,143],[208,144],[209,145],[210,146],[211,147],[212,148],[213,149],[214,150],[215,151],[216,152],[217,153],[218,154]],"affectedFilesPendingEmit":[[259,17],[64,17],[65,17],[71,17],[70,17],[80,17],[81,17],[192,17],[201,17],[73,17],[72,17],[74,17],[195,17],[79,17],[77,17],[78,17],[76,17],[75,17],[196,17],[191,17],[63,17],[198,17],[199,17],[200,17],[202,17],[69,17],[194,17],[203,17],[190,17],[66,17],[197,17],[62,17],[204,17],[205,17],[206,17],[207,17],[208,17],[209,17],[210,17],[211,17],[212,17],[213,17],[214,17],[215,17],[216,17],[217,17],[218,17]],"emitSignatures":[62,63,64,65,66,69,70,71,72,73,74,75,76,77,78,79,80,81,190,191,192,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,259],"version":"5.9.3"} \ No newline at end of file diff --git a/launcher/tsconfig.web.tsbuildinfo b/launcher/tsconfig.web.tsbuildinfo deleted file mode 100644 index 95109c7..0000000 --- a/launcher/tsconfig.web.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./src/shared/protocol.ts","./src/shared/project.ts","./locales/en.json","./locales/fr.json","./src/shared/i18n.ts","./src/shared/ipc.ts","./src/renderer/src/previews.ts","./src/renderer/src/result.ts","./src/renderer/src/session.ts","./src/renderer/src/i18n.tsx","./src/renderer/src/views/versionsview.tsx","./src/renderer/src/components/icon.tsx","./src/shared/model-preview.ts","./src/renderer/src/components/modelpreview.tsx","./src/renderer/src/views/projectview.tsx","./src/shared/settings-schema.ts","./src/renderer/src/components/settingsform.tsx","./src/renderer/src/components/texturepanel.tsx","./src/renderer/src/components/quicknewentry.tsx","./src/renderer/src/components/entryheader.tsx","./src/renderer/src/views/editorview.tsx","./src/renderer/src/views/logsview.tsx","./src/renderer/src/views/exportview.tsx","./src/renderer/src/views/settingsview.tsx","./src/renderer/src/components/statusbar.tsx","./src/renderer/src/components/quicklaunch.tsx","./src/renderer/src/components/crashdialog.tsx","./src/renderer/src/app.tsx","./node_modules/@types/react-dom/client.d.ts","./src/renderer/src/main.tsx","./src/shared/version.ts","./src/preload/index.d.ts"],"fileIdsList":[[67],[66],[64,65],[73],[66,67,76,77,78,79,82,88,89,90,91,92,93,94],[66,67,73,77],[66,67,69,73,77,81],[66,67,73,80],[66,67,73,76,77,79],[66,67,68,69,77],[67,68,77,83],[67,76,77],[66,67,73,77,79],[66,67,72],[66,67,77,95,96],[66,67,73],[67,73],[66,67,68,69,73],[66,67,74,76,77,79,84,85,86,87],[66,67,73,76,77],[66,67,76,77],[66,67,68,69,73,74,75,76,77,79,81],[66,67,72,73,77],[66,67,73,75,76,77],[67,70,71],[67,68,69,72],[67,68]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"814d5c7384f3ca276e9dc4bcfde5545801a3ea0bfae09916b3336774e662fd1b","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},"7f93ec79df97ce06a9f7e2d87091f4ff14f7fb6a882375abbca1e488bae5c2c0","0230fd5c0a569a1ab873df98051bac5e163e06adb8669f8713e794afd48f0e02",{"version":"0e25dd83bfa28c7f5fb9a961505623c1d17bc35569b087878471f83e5556e3e0","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},{"version":"980796b30a30050db7b81611ecff08804c466058d15d2bef1134b1e1422d2ef8","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},"2fcc52ac9003375e35b45f3c4e05912549ccf7a94bdf28ebf81fb9658afcf376",{"version":"cdf0d035d910b844495d5dc3464d7cbb65031bc2d70d6b3627be67789fe50901","signature":"4f43bd59fe1ea546130724ea137f99f829ba1f2e540b0f7903fe4afb9d9a33e6"},"a45b3ab8f9b028efd38e16e1fa8a79b1f31fef20711d1417b61898c6fa0af390","cd615e1735a11f27d12a0008e81d6bca3edc9e5644e73090b08a7e0054517a23","b599845d38c63f6c906ea040de2527f702d17b3b225c0202d0e0737988ca68a9","ef5fadef7d679d9101387611988e9dd30324f198f78dc2708d228323c1a619b9","be70801de725ffcf456b1929d2997b1eed405b81324a2b4ffce6798c118cd9cb",{"version":"cd89fcda5db4feee443d955464d417c74418c5c4331f829e7f59cc3f1e977465","signature":"cc1243040c70550943a85531b7e2fb42eb1b4a7617eb6bbc85e7234311f96489"},"91e985d8f993207fa3192dbd8fbff4d80065d9cfd2a1dde31b096d8dbb8871c8","e976204aaad50c2068bbf60210e4d44d038c7e6383f1d5576855c60957f2a37b",{"version":"d832dfb3e476d72a1c5d8507c83eb6cb0172edaa70c3380eef4374a064ac0c27","signature":"c731a053e2f85fb978df3ee92d073c5c60a323812121e02434e9f54fb264c5d0"},"653d38e051e88413fb0adb044643723bf5b73ac17df0edc913bce8516c5b5772","da9d36459d6ce8c1d00e29d8137473eb7473f0b53deccd900ee776575ae6db63",{"version":"dedf429b9d55862f3edfd39d428b7a76fc06b9b6adf992c92864d3d839996f4a","signature":"e2d0aa1c41eb2d6340798dea8280c85c3de3f1fc752b05748b1dedf4c2e6af4a"},"c6d924d1b1f45277a1240b142db5d15cbd56647fb3e69ef8bf45464a301d51a1","ea632c1d26f2246050cf198c3828791a570330fe70a68e456feea06864dfdd88",{"version":"8e001955f37f55dda1f66bfeccd07bd83a7ebfc473ae5f6c6c88d17a100f7a25","signature":"bb2bc9d868b03b473f518a2d231e764da5664137e061dffd06d984cc4baad67c"},"7c6fc9f2a802f48dab615e49ee3ef1af4cde4a2d1c215a1b595f45f84db50e15","4d62c3900d5996682de2311422f43e1853e98678769fb5c47265c26b224cb1d0","e88e21ff11895adfe2424c5cf76ba30d30ae704fa54f523f34e0813db21c7517","b828db1821729b2a3a94fa11dde578c411677090665f64adfa1407be92ba1166",{"version":"fdd736ba377bc76db397067645a52cd235769493c22164b71f766293b5dff6fb","signature":"3574545769654249cf615c708b4d3262fffa0c37319e0eb4203809c6960f4e27"},"019f77e4b23fecf52ac74b591706dbcb8fdf884e695b5500ed5e2bc43d372c55",{"version":"795920053edc0eaec6f11a8b72c1f1864b13d643c5d9bed4c68cfa33b52da62c","signature":"5ea98aa344d44b6d7c0dfdc744ff10a4a3a40429a11e505bb52e1204f382c600"},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},"b8dab75d9f8329e5afb4167c0dd3deb244fc995531cbb86a60bcc28ebb35a14e","6d738b4fe64ce86e7f2b349b816af4c33bc1135c684e044da17a0939a4236a11",{"version":"30f07285f58c550003df14dbd757001514739ad959142ed589afdc206f2e6494","affectsGlobalScope":true}],"root":[68,69,[72,95],[97,99]],"options":{"allowImportingTsExtensions":true,"composite":true,"esModuleInterop":true,"jsx":4,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":false,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[70,1],[71,1],[96,2],[66,3],[67,2],[99,4],[95,5],[94,6],[87,7],[79,1],[81,8],[93,9],[86,10],[84,11],[92,12],[85,13],[77,14],[97,15],[74,16],[75,17],[76,18],[88,19],[90,20],[89,21],[82,22],[91,23],[78,24],[72,25],[73,26],[80,1],[69,27],[68,1],[83,27],[98,1]],"affectedFilesPendingEmit":[[95,17],[94,17],[87,17],[79,17],[81,17],[93,17],[86,17],[84,17],[92,17],[85,17],[77,17],[97,17],[74,17],[75,17],[76,17],[88,17],[90,17],[89,17],[82,17],[91,17],[78,17],[72,17],[73,17],[80,17],[69,17],[68,17],[83,17],[98,17]],"emitSignatures":[68,69,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,97,98],"version":"5.9.3"} \ No newline at end of file diff --git a/mod/adapters/forge-1.12.2/build.gradle b/mod/adapters/forge-1.12.2/build.gradle index 54fb23e..9c9fefc 100644 --- a/mod/adapters/forge-1.12.2/build.gradle +++ b/mod/adapters/forge-1.12.2/build.gradle @@ -17,7 +17,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' archivesBaseName = 'ella-forge-1.12.2' java { diff --git a/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java b/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java index 7ef5434..d794f97 100644 --- a/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java +++ b/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java @@ -15,17 +15,26 @@ * render layers, live reloads — is a client concern. Marking it so keeps it from being * demanded of servers. */ +/* + * `acceptedMinecraftVersions` must match this adapter's entry in ADAPTERS + * (launcher/src/shared/version.ts), and a launcher test fails if the two drift. + * + * Stating it matters even when the range looks obvious: with the attribute absent FML + * derives the range from `mcversion` in mcmod.info and accepts only that exact version, + * so the launcher's offer of 1.12 and 1.12.1 would have been refused by the loader. + */ @Mod( modid = EllaMod.MOD_ID, name = "Ella", version = EllaMod.VERSION, clientSideOnly = true, + acceptedMinecraftVersions = "[1.12,1.13)", acceptableRemoteVersions = "*" ) public final class EllaMod { public static final String MOD_ID = "ella"; - public static final String VERSION = "0.1.0"; + public static final String VERSION = "0.2.0"; /** Default pool size, overridable with {@code -Della.slots.block} / {@code .item}. */ private static final int DEFAULT_SLOTS = 128; diff --git a/mod/adapters/forge-1.8.9/build.gradle b/mod/adapters/forge-1.8.9/build.gradle new file mode 100644 index 0000000..84e258e --- /dev/null +++ b/mod/adapters/forge-1.8.9/build.gradle @@ -0,0 +1,78 @@ +/* + * Ella adapter for Minecraft 1.8.9 on Forge. + * + * Unlike the other two adapters, this one runs on the era-correct toolchain rather than a + * modern one. RetroFuturaGradle — which lets 1.12.2 build on Gradle 8 — supports exactly + * two Minecraft versions, 1.7.10 and 1.12.2, because those are the ones its authors ship + * modpacks for. 1.8.9 is not one of them and no version of RFG adds it, so the only route + * is ForgeGradle 2.1, which is pinned to Gradle 2.x and Java 8. + * + * That is why this directory carries its own wrapper: `./gradlew` here launches Gradle + * 2.14.1, not the 8.2.1 the rest of the repository uses. The adapters were always + * independent builds for exactly this reason. + */ + +buildscript { + repositories { + maven { url = 'https://maven.minecraftforge.net' } + maven { url = 'https://repo.maven.apache.org/maven2' } + mavenCentral() + } + dependencies { + classpath 'net.minecraftforge.gradle:ForgeGradle:2.1-SNAPSHOT' + } +} + +apply plugin: 'net.minecraftforge.gradle.forge' + +group = 'dev.ella' +version = '0.2.0' +archivesBaseName = 'ella-forge-1.8.9' + +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +minecraft { + version = '1.8.9-11.15.1.2318-1.8.9' + runDir = 'run' + // MCP mappings for 1.8.9: `stable_22` is the last stable channel published for it. + mappings = 'stable_22' +} + +/* + * ella-core is compiled from source rather than consumed as a jar, so an adapter can + * never be built against a stale copy of the shared code. + */ +sourceSets { + main { + java { + srcDir file('../../ella-core/src/main/java') + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Gson comes from Minecraft at runtime; compiling against it must not bundle a copy. + compileOnly 'com.google.code.gson:gson:2.8.0' +} + +processResources { + inputs.property 'version', project.version + + filesMatching('mcmod.info') { + expand 'version': project.version, 'mcversion': '1.8.9' + } +} + +jar { + manifest { + attributes( + 'FMLCorePluginContainsFMLMod': 'true', + 'Implementation-Version': project.version + ) + } +} diff --git a/mod/adapters/forge-1.8.9/gradle.properties b/mod/adapters/forge-1.8.9/gradle.properties new file mode 100644 index 0000000..4594432 --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradle.properties @@ -0,0 +1,8 @@ +# Deobfuscating the 1.8.9 jar holds the whole class set in memory at once. Gradle 2.14's +# default heap is far too small for it and the build dies with "GC overhead limit +# exceeded" partway through `deobfMcMCP`. +org.gradle.jvmargs=-Xmx3G + +# Deliberately not setting org.gradle.java.home: this build needs a Java 8 JVM, but the +# path to one differs per machine. Pass it at the command line instead — +# `./gradlew build -Dorg.gradle.java.home=/path/to/jdk-8` — or export JAVA_HOME. diff --git a/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar b/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.properties b/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b35dbe7 --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod/adapters/forge-1.8.9/gradlew b/mod/adapters/forge-1.8.9/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod/adapters/forge-1.8.9/gradlew.bat b/mod/adapters/forge-1.8.9/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java new file mode 100644 index 0000000..94ea8d0 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java @@ -0,0 +1,251 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.block.properties.PropertyDirection; +import net.minecraft.block.state.BlockState; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.util.AxisAlignedBB; +import net.minecraft.util.BlockPos; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumWorldBlockLayer; +import net.minecraft.world.Explosion; +import net.minecraft.world.IBlockAccess; +import net.minecraft.world.World; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** + * A placeholder block whose behaviour comes from a {@link SlotSettings} object rather + * than from constructor arguments. + * + *

This inversion is the whole trick. A stock block bakes hardness, render layer and + * shape in at construction, so changing any of them means registering a new block — which + * frozen registries forbid. Reading each property on demand means the launcher can change + * them by writing to a field, and the next query picks it up. + * + *

Three differences from the 1.12.2 adapter, all forced by the older API and all + * verified against the decompiled 1.8.9 sources rather than assumed: + * + *

    + *
  • {@code isOpaqueCube()} and {@code isFullCube()} take no block state. On 1.8.9 they + * are properties of the block itself, so a slot cannot vary them per state — which + * costs nothing here, since every Ella slot has exactly one meaningful appearance. + *
  • There is no {@code getBoundingBox}. Bounds are mutable fields set through + * {@link #setBlockBoundsBasedOnState}, which the game calls before it reads them. + *
  • Sound is a public field rather than a getter, so it is assigned rather than + * overridden — see {@link #applySound()}. + *
+ */ +public class EllaBlock extends Block { + + /** + * Stand-in used while {@link Block}'s constructor is still running. + * + *

{@code Block.} builds its default block state, and that calls overridable + * methods such as {@link #isOpaqueCube} before this class's fields are + * assigned — so {@link #settings} is genuinely null for the duration of {@code super()}. + * Reading it directly there throws, and the stack trace points at the override rather + * than at the constructor that provoked it. + * + *

Shared and never mutated: it only ever supplies defaults during construction. + */ + private static final SlotSettings DEFAULTS = new SlotSettings(); + + private final SlotSettings settings; + private final int slot; + + /** + * Orientation, present on every slot. + * + *

State properties are baked into the {@link BlockState} at construction, exactly + * like registry entries are frozen after startup. A slot that might later be made + * rotatable therefore has to carry the property from the start; the {@code rotation} + * setting only decides whether placement uses it. + */ + public static final PropertyDirection FACING = PropertyDirection.create("facing"); + + public EllaBlock(SlotSettings settings, int slot) { + // Material.rock is only a starting point; the properties that matter are all + // overridden below to read from `settings`. + super(Material.rock); + this.settings = settings; + this.slot = slot; + + setHardness(1.5f); + setResistance(6.0f); + setDefaultState(this.blockState.getBaseState().withProperty(FACING, EnumFacing.NORTH)); + } + + // --- state --------------------------------------------------------------- + + @Override + protected BlockState createBlockState() { + return new BlockState(this, FACING); + } + + @Override + public IBlockState getStateFromMeta(int meta) { + // Six directions fit in three bits; anything else is a corrupt value, so fall + // back to north rather than throwing during world load. + EnumFacing facing = EnumFacing.getFront(meta); + return getDefaultState().withProperty(FACING, facing); + } + + @Override + public int getMetaFromState(IBlockState state) { + return state.getValue(FACING).getIndex(); + } + + /** + * Chooses the facing a newly placed block gets. + * + *

1.8.9's hook is {@code onBlockPlaced} rather than the {@code getStateForPlacement} + * of later versions. With rotation off this always returns north, so the block renders + * exactly as authored — the property exists but is never varied. + */ + @Override + public IBlockState onBlockPlaced(World world, BlockPos pos, EnumFacing face, + float hitX, float hitY, float hitZ, int meta, + EntityLivingBase placer) { + String mode = live().rotation; + + if ("horizontal".equals(mode)) { + // Facing the player, which is what furnaces and chests do. + return getDefaultState().withProperty(FACING, placer.getHorizontalFacing().getOpposite()); + } + if ("all".equals(mode)) { + // The face that was clicked, like pistons. + return getDefaultState().withProperty(FACING, face); + } + return getDefaultState().withProperty(FACING, EnumFacing.NORTH); + } + + /** + * The live settings, or shared defaults while the superclass constructor is running. + * Every override below goes through this rather than touching the field. + */ + private SlotSettings live() { + SlotSettings current = settings; + return current != null ? current : DEFAULTS; + } + + public int slot() { + return slot; + } + + public SlotSettings settings() { + return settings; + } + + /** + * Copies the slot's sound onto the block. + * + *

1.8.9 has no {@code getSoundType} hook — {@code stepSound} is a plain public + * field the game reads directly, so keeping it current means assigning it whenever the + * setting changes. Called from the host on every settings patch. + */ + void applySound() { + this.stepSound = Sounds.byName(live().soundType); + } + + // --- appearance --------------------------------------------------------- + + /** 1.8.9 names both the method and the enum differently from later versions. */ + @Override + @SideOnly(Side.CLIENT) + public EnumWorldBlockLayer getBlockLayer() { + String layer = live().renderLayer; + if ("cutout".equals(layer)) return EnumWorldBlockLayer.CUTOUT; + if ("cutout_mipped".equals(layer)) return EnumWorldBlockLayer.CUTOUT_MIPPED; + if ("translucent".equals(layer)) return EnumWorldBlockLayer.TRANSLUCENT; + return EnumWorldBlockLayer.SOLID; + } + + /** + * Controls whether neighbouring faces are culled. Returning false is what actually + * makes a transparent block look transparent — the render layer alone is not enough, + * which is the mistake the editor warns about. + */ + @Override + public boolean isOpaqueCube() { + return live().opaque; + } + + @Override + public boolean isFullCube() { + return live().fullCube; + } + + @Override + public int getLightValue(IBlockAccess world, BlockPos pos) { + return live().lightLevel; + } + + @Override + public int getLightOpacity(IBlockAccess world, BlockPos pos) { + // A non-opaque block must not block light, otherwise glass-like models render + // with a black interior. + return live().opaque ? 255 : 0; + } + + // --- physical ----------------------------------------------------------- + + @Override + public float getBlockHardness(World world, BlockPos pos) { + return live().hardness; + } + + @Override + public float getExplosionResistance(World world, BlockPos pos, Entity exploder, + Explosion explosion) { + // Minecraft stores resistance pre-divided by 5; the launcher exposes the value + // players actually recognise, so convert here rather than in the editor. + return live().resistance / 5.0f; + } + + // --- shape -------------------------------------------------------------- + + /** + * 1.8.9 reads a block's shape from mutable fields rather than from a returned box, and + * calls this first so the block can set them for the state at hand. Everything that + * asks about our bounds therefore goes through here. + */ + @Override + public void setBlockBoundsBasedOnState(IBlockAccess world, BlockPos pos) { + applyBounds(); + } + + @Override + public void setBlockBoundsForItemRender() { + applyBounds(); + } + + private void applyBounds() { + if (!"custom".equals(live().collision)) { + setBlockBounds(0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f); + return; + } + float[] box = live().hitboxNormalised(); + setBlockBounds(box[0], box[1], box[2], box[3], box[4], box[5]); + } + + @Override + public AxisAlignedBB getCollisionBoundingBox(World world, BlockPos pos, IBlockState state) { + // Null is 1.8.9's "no collision at all", the equivalent of the later NULL_AABB. + if ("none".equals(live().collision)) return null; + applyBounds(); + return super.getCollisionBoundingBox(world, pos, state); + } + + @Override + public boolean shouldSideBeRendered(IBlockAccess world, BlockPos pos, EnumFacing side) { + // Let two adjacent non-opaque blocks of the same kind still draw their shared + // faces; hiding them is only correct for full opaque cubes. + if (!live().opaque || !live().fullCube) return true; + return super.shouldSideBeRendered(world, pos, side); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java new file mode 100644 index 0000000..8822350 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java @@ -0,0 +1,82 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.EnumRarity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import java.util.List; + +/** + * A placeholder item, reading its properties from a live {@link SlotSettings} for the + * same reason {@link EllaBlock} does. + */ +public class EllaItem extends Item { + + /** Same guard as EllaBlock: protects reads during superclass construction. */ + private static final SlotSettings DEFAULTS = new SlotSettings(); + + private final SlotSettings settings; + private final int slot; + + public EllaItem(SlotSettings settings, int slot) { + this.settings = settings; + this.slot = slot; + // The real limit is enforced by getItemStackLimit below; this only sets a + // starting point for code that reads the field directly. + setMaxStackSize(64); + } + + private SlotSettings live() { + SlotSettings current = settings; + return current != null ? current : DEFAULTS; + } + + public int slot() { + return slot; + } + + public SlotSettings settings() { + return settings; + } + + @Override + public int getItemStackLimit(ItemStack stack) { + return live().stackSize; + } + + @Override + public EnumRarity getRarity(ItemStack stack) { + String rarity = live().rarity; + if ("uncommon".equals(rarity)) return EnumRarity.UNCOMMON; + if ("rare".equals(rarity)) return EnumRarity.RARE; + if ("epic".equals(rarity)) return EnumRarity.EPIC; + return EnumRarity.COMMON; + } + + @Override + public boolean hasEffect(ItemStack stack) { + return live().glint || super.hasEffect(stack); + } + + /** + * Bound slots only — see {@link EllaItemBlock} for why. + * + *

1.8.9 passes the item and a plain {@code List}; the {@code NonNullList} of later + * versions does not exist yet. + */ + @Override + @SideOnly(Side.CLIENT) + public void getSubItems(Item item, CreativeTabs tab, List items) { + if (live().isBound()) items.add(new ItemStack(this)); + } + + @Override + public String getItemStackDisplayName(ItemStack stack) { + String name = live().displayName; + return name == null || name.isEmpty() ? super.getItemStackDisplayName(stack) : name; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java new file mode 100644 index 0000000..20abad2 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java @@ -0,0 +1,57 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemBlock; +import net.minecraft.item.ItemStack; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import java.util.List; + +/** + * The item form of a placeholder block. + * + *

Exists to keep unbound slots out of the creative tab. The pool holds 128 blocks so + * that binding one needs no restart, but showing all 128 fills the tab with identical + * untextured cubes — and picking one of those is indistinguishable from a bug in the + * block you were actually working on. + * + *

The constructor takes a plain {@link Block} rather than an {@link EllaBlock} because + * {@code GameRegistry.registerBlock} instantiates the item class reflectively, looking for + * exactly that signature. + */ +public class EllaItemBlock extends ItemBlock { + + private final SlotSettings settings; + + public EllaItemBlock(Block block) { + super(block); + this.settings = block instanceof EllaBlock ? ((EllaBlock) block).settings() : null; + } + + @Override + @SideOnly(Side.CLIENT) + public void getSubItems(Item item, CreativeTabs tab, List items) { + // Bound slots only. An unbound one has no model and no name to show. + if (settings != null && settings.isBound()) { + items.add(new ItemStack(this)); + } + } + + /** + * The name shown in the tab and hotbar. + * + *

Read from the slot rather than a translation key: the launcher already knows the + * display name and pushes it over the protocol, so it is live and needs no resource + * reload. The translation key remains as the fallback for a slot bound before the + * name arrives. + */ + @Override + public String getItemStackDisplayName(ItemStack stack) { + String name = settings == null ? null : settings.displayName; + return name == null || name.isEmpty() ? super.getItemStackDisplayName(stack) : name; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java new file mode 100644 index 0000000..591051b --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java @@ -0,0 +1,44 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaLog; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Routes {@link EllaLog} through Forge's Log4j logger, so Ella's output lands in the + * game log alongside everything else rather than on bare stderr. + */ +final class EllaLogBridge { + + private static final Logger LOGGER = LogManager.getLogger("Ella"); + + private EllaLogBridge() { + } + + static void install() { + EllaLog.setSink(new EllaLog.Sink() { + @Override + public void log(EllaLog.Level level, String message, Throwable error) { + switch (level) { + case DEBUG: + LOGGER.debug(message, error); + break; + case WARN: + LOGGER.warn(message, error); + break; + case ERROR: + LOGGER.error(message, error); + break; + case INFO: + default: + LOGGER.info(message, error); + break; + } + } + }); + } + + static void info(String message) { + LOGGER.info(message); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java new file mode 100644 index 0000000..479bbc4 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java @@ -0,0 +1,107 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaCore; +import dev.ella.core.EllaLog; +import dev.ella.core.SlotPool; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; +import net.minecraftforge.fml.common.event.FMLServerStoppingEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** + * Ella's 1.8.9 entry point. + * + *

Client-side only: Ella is a modelling tool, and everything it does — resource packs, + * render layers, live reloads — is a client concern. Marking it so keeps it from being + * demanded of servers. + * + *

Registration happens inline here rather than through the registry events of later + * versions, which 1.8.9 does not have. + */ +/* + * `acceptedMinecraftVersions` must match this adapter's entry in ADAPTERS + * (launcher/src/shared/version.ts), and a launcher test fails if the two drift. + * + * Without it FML derives the range from `mcversion` in mcmod.info, which yields the exact + * version and nothing else — the mod then refuses to load on 1.8.8 with "Ella (ella) wants + * Minecraft [1.8.9,1.8.9]" while the launcher cheerfully offered 1.8.8 as live-editable. + * The mappings say one jar serves both versions; this is where that has to be said out + * loud to the loader. + */ +@Mod( + modid = EllaMod.MOD_ID, + name = "Ella", + version = EllaMod.VERSION, + clientSideOnly = true, + acceptedMinecraftVersions = "[1.8.8,1.9)", + acceptableRemoteVersions = "*" +) +public final class EllaMod { + + public static final String MOD_ID = "ella"; + public static final String VERSION = "0.2.0"; + + /** Default pool size, overridable with {@code -Della.slots.block} / {@code .item}. */ + private static final int DEFAULT_SLOTS = 128; + + private static SlotPool pool; + private static EllaCore core; + + public static SlotPool pool() { + return pool; + } + + @Mod.EventHandler + public void preInit(FMLPreInitializationEvent event) { + EllaLogBridge.install(); + + int blocks = slotCount("ella.slots.block"); + int items = slotCount("ella.slots.item"); + pool = new SlotPool(blocks, items); + + EllaLog.info("Ella " + VERSION + " starting with " + blocks + " block and " + + items + " item slots"); + + Registration.registerAll(pool); + registerClientModels(); + } + + /** + * Split out and side-guarded so the class loader never touches the client-only model + * API on a server. Called from preInit because 1.8.9 bakes models before init. + */ + @SideOnly(Side.CLIENT) + private static void registerClientModels() { + Registration.registerModels(); + } + + @Mod.EventHandler + public void init(FMLInitializationEvent event) { + // Started after registration so the pool is fully populated before the launcher + // can bind anything to it. + core = EllaCore.fromSystemProperties(new ForgeHost(pool), pool); + if (core != null) core.start(); + } + + @Mod.EventHandler + public void serverStopping(FMLServerStoppingEvent event) { + // Slots hold references to world state indirectly; clearing on world unload + // avoids a stale binding pointing at a world that no longer exists. + if (pool != null) pool.clearAll(); + } + + private static int slotCount(String property) { + String configured = System.getProperty(property); + if (configured == null) return DEFAULT_SLOTS; + + try { + // Bounds match the launcher's own clamp, so the two cannot disagree. + return Math.max(16, Math.min(1024, Integer.parseInt(configured))); + } catch (NumberFormatException malformed) { + EllaLog.warn("Ignoring malformed " + property + ": " + configured); + return DEFAULT_SLOTS; + } + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java new file mode 100644 index 0000000..02d9632 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java @@ -0,0 +1,98 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaLog; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.FolderResourcePack; +import net.minecraft.client.resources.IResourcePack; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.List; + +/** + * Makes the launcher's workspace directory part of the client's resource stack. + * + *

{@code Minecraft.defaultResourcePacks} is private with no accessor, so this goes + * through reflection. An access transformer would be tidier, but ATs are configured + * per-loader and would have to be rewritten for each adapter; reflection keeps the trick + * contained in one file per version. + * + *

Two field names are tried because a Forge mod runs against MCP names in a development + * workspace and SRG names in production, and the same jar has to work in both. The SRG + * name happens to be identical to 1.12.2's here, which is luck rather than a rule — it was + * read out of the 1.8.9 mappings, not carried over. + */ +final class EllaResources { + + /** MCP name (development) and SRG name (production) for defaultResourcePacks. */ + private static final String[] FIELD_NAMES = { "defaultResourcePacks", "field_110449_ao" }; + + private static File injected; + + private EllaResources() { + } + + /** + * Adds {@code packRoot} to the resource stack if it is not already there. + * + * @return true when the pack is in place + */ + static synchronized boolean inject(File packRoot) { + if (packRoot == null || !packRoot.isDirectory()) { + EllaLog.warn("Workspace pack directory does not exist: " + packRoot); + return false; + } + + // Re-injecting the same directory on every reconnect would stack duplicate packs. + if (packRoot.equals(injected)) return true; + + List packs = resourcePackList(); + if (packs == null) return false; + + try { + packs.add(new FolderResourcePack(packRoot) { + @Override + public String getPackName() { + return "Ella workspace"; + } + }); + injected = packRoot; + EllaLog.info("Injected workspace resource pack: " + packRoot.getAbsolutePath()); + return true; + } catch (RuntimeException failed) { + EllaLog.error("Could not add the workspace resource pack", failed); + return false; + } + } + + @SuppressWarnings("unchecked") + private static List resourcePackList() { + Minecraft client = Minecraft.getMinecraft(); + + for (String name : FIELD_NAMES) { + try { + Field field = Minecraft.class.getDeclaredField(name); + field.setAccessible(true); + Object value = field.get(client); + if (value instanceof List) { + return (List) value; + } + } catch (NoSuchFieldException wrongName) { + // Expected for whichever of the two names does not apply here. + } catch (IllegalAccessException blocked) { + EllaLog.error("Access to Minecraft." + name + " was denied", blocked); + return null; + } + } + + EllaLog.error( + "Could not find Minecraft's resource pack list under any known field name; " + + "live model loading is unavailable on this build.", null); + return null; + } + + /** Whether a workspace pack is currently injected. */ + static boolean isInjected() { + return injected != null; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java new file mode 100644 index 0000000..dcb483a --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java @@ -0,0 +1,279 @@ +package dev.ella.forge189; + +import dev.ella.core.Capabilities; +import dev.ella.core.EllaHost; +import dev.ella.core.EllaLog; +import dev.ella.core.SlotPool; +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.BlockPos; +import net.minecraft.util.MathHelper; +import net.minecraft.world.World; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The 1.8.9 implementation of {@link EllaHost}. + * + *

Every method here can be called from the IPC thread, so anything touching the game + * is handed to the client or server scheduler. Doing the work inline would be a data race + * against the render and tick loops — the kind that produces crashes far from the cause. + */ +public final class ForgeHost implements EllaHost { + + private static final Set CAPABILITIES = new HashSet(Arrays.asList( + Capabilities.RENDER_LAYER_CUTOUT, + Capabilities.RENDER_LAYER_CUTOUT_MIPPED, + Capabilities.RENDER_LAYER_TRANSLUCENT, + Capabilities.RENDER_LAYER_RUNTIME, + Capabilities.LIGHT_DYNAMIC, + Capabilities.HITBOX_CUSTOM, + Capabilities.HITBOX_RUNTIME, + Capabilities.RELOAD_PROGRAMMATIC, + Capabilities.ENTRY_PLACE, + Capabilities.BLOCK_ROTATION + // Same omissions as 1.12.2: ITEM_RARITY predates the launcher's enum, MODEL_OBJ + // needs the Forge OBJ loader — which 1.8.9 does not have at all — and + // ITEM_COMPONENTS only exists from 1.20.5. + )); + + private final SlotPool pool; + + public ForgeHost(SlotPool pool) { + this.pool = pool; + } + + // --- identity ----------------------------------------------------------- + + @Override + public String minecraftVersion() { + return "1.8.9"; + } + + @Override + public String loaderName() { + return "forge"; + } + + @Override + public String loaderVersion() { + return net.minecraftforge.common.ForgeVersion.getVersion(); + } + + @Override + public String adapterId() { + return "forge-1.8.9"; + } + + @Override + public String adapterVersion() { + return EllaMod.VERSION; + } + + @Override + public int packFormat() { + // 1.8 and 1.8.9 use pack_format 1. Must match src/main/resources/pack.mcmeta, + // which Forge reads from the mod jar itself. + return 1; + } + + @Override + public Set capabilities() { + return CAPABILITIES; + } + + @Override + public int slotCount(String kind) { + return pool.size(kind); + } + + // --- slots -------------------------------------------------------------- + + @Override + public void onSlotAssigned(String kind, int slot, SlotSettings settings) { + if (!SlotPool.BLOCK.equals(kind)) return; + + // Sound is a field on this version rather than a getter, so it has to be pushed + // rather than pulled — see EllaBlock.applySound. + EllaBlock block = Registration.block(slot); + if (block != null) block.applySound(); + + scheduleClient(new Runnable() { + @Override + public void run() { + refreshWorldRenderers(); + } + }); + } + + @Override + public void onSlotCleared(String kind, int slot) { + onSlotAssigned(kind, slot, pool.get(kind, slot)); + } + + @Override + public List onSettingsPatched(String kind, int slot, List requested, + List applied) { + boolean needsRerender = false; + for (String key : applied) { + if ("soundType".equals(key) && SlotPool.BLOCK.equals(kind)) { + EllaBlock block = Registration.block(slot); + if (block != null) block.applySound(); + } + if ("renderLayer".equals(key) || "opaque".equals(key) || "fullCube".equals(key) + || "lightLevel".equals(key) || "emissive".equals(key)) { + needsRerender = true; + } + } + + if (needsRerender) { + scheduleClient(new Runnable() { + @Override + public void run() { + refreshWorldRenderers(); + } + }); + } + + // Every key core managed to apply is genuinely honoured on this version, except + // the ones whose capability is not declared — the launcher greys those out, so + // reaching here with one means the project targets a newer version. + List honoured = new ArrayList(); + for (String key : applied) { + if ("rarity".equals(key) && !CAPABILITIES.contains(Capabilities.ITEM_RARITY)) { + continue; + } + honoured.add(key); + } + return honoured; + } + + // --- resources ---------------------------------------------------------- + + @Override + public void injectResourcePack(final File packRoot) { + scheduleClient(new Runnable() { + @Override + public void run() { + if (EllaResources.inject(packRoot)) { + Minecraft.getMinecraft().refreshResources(); + } + } + }); + } + + @Override + public void reloadResources() { + scheduleClient(new Runnable() { + @Override + public void run() { + // The programmatic equivalent of F3+T. + Minecraft.getMinecraft().refreshResources(); + refreshWorldRenderers(); + } + }); + } + + /** Forces chunk meshes to rebuild so shape and render-layer changes become visible. */ + private static void refreshWorldRenderers() { + Minecraft client = Minecraft.getMinecraft(); + if (client.renderGlobal != null) { + client.renderGlobal.loadRenderers(); + } + } + + // --- player actions ----------------------------------------------------- + + @Override + public void giveToPlayer(final String kind, final int slot, final int count) { + final MinecraftServer server = requireServer(); + + server.addScheduledTask(new Runnable() { + @Override + public void run() { + EntityPlayerMP player = firstPlayer(server); + if (player == null) return; + + ItemStack stack = stackFor(kind, slot, count); + if (stack == null) { + EllaLog.warn("No registered " + kind + " for slot " + slot); + return; + } + + // Going through the server player rather than the client inventory is + // what makes the item actually persist rather than vanish next tick. + if (!player.inventory.addItemStackToInventory(stack)) { + // 1.8.9's three-argument form: the extra flag is "trace to the player", + // which keeps a dropped item from landing behind them. + player.dropItem(stack, false, false); + } + } + }); + } + + @Override + public void placeInFrontOfPlayer(final String kind, final int slot) { + if (!SlotPool.BLOCK.equals(kind)) { + throw new IllegalStateException("Only blocks can be placed"); + } + + final MinecraftServer server = requireServer(); + + server.addScheduledTask(new Runnable() { + @Override + public void run() { + EntityPlayerMP player = firstPlayer(server); + if (player == null) return; + + Block block = Registration.block(slot); + if (block == null) return; + + // Two blocks ahead at eye level, which lands in view without landing + // inside the player. + double yaw = Math.toRadians(player.rotationYaw); + int x = MathHelper.floor_double(player.posX - Math.sin(yaw) * 2.0); + int z = MathHelper.floor_double(player.posZ + Math.cos(yaw) * 2.0); + int y = MathHelper.floor_double(player.posY); + + World world = player.worldObj; + BlockPos position = new BlockPos(x, y, z); + world.setBlockState(position, block.getDefaultState(), 3); + } + }); + } + + private ItemStack stackFor(String kind, int slot, int count) { + Item item = SlotPool.BLOCK.equals(kind) + ? Registration.blockItem(slot) + : Registration.item(slot); + return item == null ? null : new ItemStack(item, Math.max(1, count)); + } + + private static EntityPlayerMP firstPlayer(MinecraftServer server) { + List players = server.getConfigurationManager().playerEntityList; + return players.isEmpty() ? null : players.get(0); + } + + /** @throws IllegalStateException when no world is loaded, which core maps to NOT_IN_WORLD */ + private static MinecraftServer requireServer() { + MinecraftServer server = Minecraft.getMinecraft().getIntegratedServer(); + if (server == null) { + throw new IllegalStateException("No world is loaded"); + } + return server; + } + + private static void scheduleClient(Runnable task) { + Minecraft.getMinecraft().addScheduledTask(task); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java new file mode 100644 index 0000000..ccd57d0 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java @@ -0,0 +1,127 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotPool; +import net.minecraft.block.Block; +import net.minecraft.client.resources.model.ModelResourceLocation; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemBlock; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.client.model.ModelLoader; +import net.minecraftforge.fml.common.registry.GameRegistry; + +import java.util.ArrayList; +import java.util.List; + +/** + * Registers the slot pool during mod startup. + * + *

Everything here happens while registries are still open. Nothing is registered later, + * because nothing can be: binding a project entry to a slot at runtime only changes the + * settings object a placeholder already reads from. + * + *

1.8.9 has neither the registry events nor the {@code @Mod.EventBusSubscriber} of + * later versions, so this is called directly from preInit rather than driven by the event + * bus. {@code GameRegistry.registerBlock} builds the item form reflectively, which is why + * {@link EllaItemBlock} takes a plain {@code Block}. + */ +public final class Registration { + + private static final List BLOCKS = new ArrayList(); + private static final List ITEMS = new ArrayList(); + private static final List BLOCK_ITEMS = new ArrayList(); + + /** + * Creative tab so the placeholders can be found without the launcher running. + * + *

1.8.9's tab supplies an {@link Item} rather than an {@link net.minecraft.item.ItemStack}. + */ + public static final CreativeTabs TAB = new CreativeTabs(EllaMod.MOD_ID) { + @Override + public Item getTabIconItem() { + return BLOCK_ITEMS.isEmpty() + ? Item.getItemFromBlock(net.minecraft.init.Blocks.stone) + : BLOCK_ITEMS.get(0); + } + }; + + private Registration() { + } + + public static List blocks() { + return BLOCKS; + } + + public static List items() { + return ITEMS; + } + + /** Called from preInit, before FML freezes the registries. */ + static void registerAll(SlotPool pool) { + for (int slot = 0; slot < pool.size(SlotPool.BLOCK); slot++) { + EllaBlock block = new EllaBlock(pool.get(SlotPool.BLOCK, slot), slot); + String path = SlotPool.registryPath(SlotPool.BLOCK, slot); + + block.setUnlocalizedName(EllaMod.MOD_ID + "." + path); + block.setCreativeTab(TAB); + block.applySound(); + + GameRegistry.registerBlock(block, EllaItemBlock.class, path); + + BLOCKS.add(block); + // Fetched back rather than constructed: the registry made the instance, so + // this is the only reference that is certainly the registered one. + Item itemForm = Item.getItemFromBlock(block); + if (itemForm instanceof ItemBlock) BLOCK_ITEMS.add((ItemBlock) itemForm); + } + + for (int slot = 0; slot < pool.size(SlotPool.ITEM); slot++) { + EllaItem item = new EllaItem(pool.get(SlotPool.ITEM, slot), slot); + String path = SlotPool.registryPath(SlotPool.ITEM, slot); + + item.setUnlocalizedName(EllaMod.MOD_ID + "." + path); + item.setCreativeTab(TAB); + + GameRegistry.registerItem(item, path); + ITEMS.add(item); + } + + EllaLogBridge.info("Registered " + BLOCKS.size() + " block and " + ITEMS.size() + + " item slots"); + } + + /** + * Points every placeholder at its inventory model. + * + *

Called from client preInit: 1.8.9 has no model registry event, and the mapping + * has to be in place before the model system bakes. The model files themselves come + * from the launcher's workspace pack, which is why the pack must be injected before + * the first resource load rather than after. + */ + static void registerModels() { + for (int slot = 0; slot < BLOCK_ITEMS.size(); slot++) { + bindModel(BLOCK_ITEMS.get(slot), SlotPool.registryPath(SlotPool.BLOCK, slot)); + } + for (int slot = 0; slot < ITEMS.size(); slot++) { + bindModel(ITEMS.get(slot), SlotPool.registryPath(SlotPool.ITEM, slot)); + } + } + + private static void bindModel(Item item, String path) { + ModelLoader.setCustomModelResourceLocation( + item, 0, + new ModelResourceLocation(new ResourceLocation(EllaMod.MOD_ID, path), "inventory")); + } + + public static ItemBlock blockItem(int slot) { + return slot >= 0 && slot < BLOCK_ITEMS.size() ? BLOCK_ITEMS.get(slot) : null; + } + + public static EllaBlock block(int slot) { + return slot >= 0 && slot < BLOCKS.size() ? BLOCKS.get(slot) : null; + } + + public static EllaItem item(int slot) { + return slot >= 0 && slot < ITEMS.size() ? ITEMS.get(slot) : null; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java new file mode 100644 index 0000000..a0c1901 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java @@ -0,0 +1,35 @@ +package dev.ella.forge189; + +import net.minecraft.block.Block; + +/** + * Maps Ella's version-neutral sound names onto 1.8.9's sound types. + * + *

The editor deliberately exposes generic names rather than a version's own enum, so + * one project can target 1.8.9 and 26.2 at once. Translating them is an adapter's job. + * + *

1.8.9 predates the {@code SoundType} class of later versions: the types are static + * fields on {@link Block} and the nested class is {@code Block.SoundType}. Two of the + * names also differ from their modern spellings — gravel is {@code soundTypeGravel} here + * rather than {@code GROUND}, and wool is {@code soundTypeCloth}. + */ +final class Sounds { + + private Sounds() { + } + + static Block.SoundType byName(String name) { + if (name == null) return Block.soundTypeStone; + + if ("wood".equals(name)) return Block.soundTypeWood; + if ("gravel".equals(name)) return Block.soundTypeGravel; + if ("grass".equals(name)) return Block.soundTypeGrass; + if ("metal".equals(name)) return Block.soundTypeMetal; + if ("glass".equals(name)) return Block.soundTypeGlass; + if ("wool".equals(name)) return Block.soundTypeCloth; + if ("sand".equals(name)) return Block.soundTypeSand; + if ("snow".equals(name)) return Block.soundTypeSnow; + + return Block.soundTypeStone; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info b/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info new file mode 100644 index 0000000..9b2e30e --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info @@ -0,0 +1,15 @@ +[ + { + "modid": "ella", + "name": "Ella", + "description": "Live Blockbench model testing. Binds project entries to a pool of placeholder blocks and items so models can be edited without restarting the game.", + "version": "${version}", + "mcversion": "${mcversion}", + "url": "", + "authorList": [], + "credits": "", + "logoFile": "", + "screenshots": [], + "dependencies": [] + } +] diff --git a/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta b/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..d121159 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Ella", + "pack_format": 1 + } +} diff --git a/mod/adapters/forge-modern/build.gradle b/mod/adapters/forge-modern/build.gradle index c4b9411..126f520 100644 --- a/mod/adapters/forge-modern/build.gradle +++ b/mod/adapters/forge-modern/build.gradle @@ -12,7 +12,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' archivesBaseName = 'ella-forge-modern' java { diff --git a/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java b/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java index 729cbb8..f6f6573 100644 --- a/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java +++ b/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java @@ -21,7 +21,7 @@ public final class EllaMod { public static final String MOD_ID = "ella"; - public static final String VERSION = "0.1.0"; + public static final String VERSION = "0.2.0"; private static final int DEFAULT_SLOTS = 128; diff --git a/mod/ella-core/build.gradle b/mod/ella-core/build.gradle index 0f8601a..d6a4b30 100644 --- a/mod/ella-core/build.gradle +++ b/mod/ella-core/build.gradle @@ -14,7 +14,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' java { // Toolchain rather than sourceCompatibility: this must compile on a machine whose