Compare commits

...

11 Commits

Author SHA1 Message Date
Matt 4315696136 launcher: add EnableWindowsTargeting for cross-compile from Linux
Microsoft.NET.Sdk.WindowsDesktop emits NETSDK1100 when a Windows-
targeting project is built from a non-Windows host. Setting
EnableWindowsTargeting=true opts in to the cross-compile path, which
works on Linux as long as the WindowsDesktop SDK files are present.

On glados they were copied from a Windows .NET 8 SDK archive into
/usr/lib/dotnet/sdk/8.0.126/Sdks/Microsoft.NET.Sdk.WindowsDesktop/
and /usr/lib/dotnet/packs/Microsoft.WindowsDesktop.App.Ref/. This is
a one-time setup per Linux host -- see the bridge-claude memory note.

No-op on Windows hosts (the flag has no effect when the SDK is native).
Both PC builds and glados builds now produce the same output.
2026-05-23 16:40:23 +00:00
Matt af45997a8b Merge pull request 'launcher: version 0.4.9' (#29) from chore/launcher-version-bump into main 2026-05-23 16:35:13 +00:00
Matt 444cadd921 launcher: bump version 0.4.8 -> 0.4.9 for session-refresh fix
So existing 0.4.8 installs see the upgrade banner in their current
launcher after the next deploy. The actual session-refresh code change
landed in the previous commit (DoLaunchAsync pre-launch refresh).
2026-05-23 16:35:13 +00:00
Matt 36acbafea5 Merge pull request 'launcher: refresh Microsoft session before launch' (#28) from feature/launcher-pre-launch-refresh into main 2026-05-23 16:31:17 +00:00
Matt 8e00526d27 launcher: refresh Microsoft session right before launch
Microsoft access tokens have a ~1 hour TTL. The launcher cached the
MSession once at sign-in and re-used it on every Play click, so leaving
the launcher open for >1 hour and then hitting Play sent stale
credentials to MC -- Mojang's session check on the server then rejected
the join with "Invalid session".

Fix: in DoLaunchAsync, before assembling the MC launch command, call
_auth.TryAuthenticateSilentlyAsync(). XboxAuthNet's silent flow uses
the cached refresh token (~14 day TTL) to mint a fresh access token
transparently. On success ApplySession swaps in the new MSession and
we launch with valid credentials. On failure (refresh token also
stale) the launcher prompts the player to sign in again -- they keep
the launcher open, hit Sign In, no MC restart needed.

No new dependencies, no UI change beyond the brief "Refreshing
session..." status line, no perf cost beyond the ~500ms HTTP roundtrip
to Microsoft each launch.

Bump the launcher version in launcher/ModpackLauncher.csproj before
publishing so old launcher installs see the upgrade banner.
2026-05-23 16:31:17 +00:00
Matt 67870f0648 Merge pull request 'Drop jei_hides.js (wrong JEI API binding)' (#27) from fix/drop-jei-hides into main 2026-05-23 16:22:01 +00:00
Matt d24f7f03a3 kubejs: drop jei_hides.js (wrong API binding), 0.21.1 -> 0.21.2
Two consecutive client_scripts errors traced to jei_hides.js:
1. Array-spread syntax not supported by Rhino (fixed in 0.21.1)
2. JEIEvents global is not defined in KubeJS 2101.7 -- the actual JEI
   event API uses classes like JEIRemoveEntriesKubeEvent but the binding
   name is something different we haven't identified yet.

Rather than ship more guesses, drop the script entirely. Functional
impact is minimal: recipes for waystones + coins are still removed
server-side (recipes_waystones.js, recipes_numismatics.js), so players
cannot craft these items even if they appear in JEI search. The only
loss is some JEI clutter.

When we identify the correct JEI hide API in KubeJS 2101.x, can add
back as kubejs/client_scripts/jei_hides.js with the right binding.
2026-05-23 16:22:01 +00:00
Matt 28a02d0ced Merge pull request 'jei_hides: Rhino-compatible (no array spread)' (#26) from fix/jei-hides-rhino-spread into main 2026-05-23 16:18:51 +00:00
Matt d5fd2c97e9 jei_hides: use .concat instead of array-spread (Rhino compat). 0.21.0 -> 0.21.1.
KubeJS uses Rhino as its JS engine, which doesn't support array-spread
syntax (`[...a, ...b]`). The line `[...WAYSTONE_ITEMS, ...COIN_ITEMS]
.forEach(...)` threw EvaluatorException: syntax error at jei_hides.js:46
on client launch, surfacing the KubeJS client script errors dialog.

Fix: use Array.prototype.concat() which is universal JS. Also extracted
the hide-quietly helper into a named function for readability.
2026-05-23 16:18:50 +00:00
Matt ae061a5035 Merge pull request 'v0.21.0: Plot system V1' (#25) from feature/plots-v1 into main 2026-05-23 16:02:05 +00:00
Matt 6264785f2c plots: v1 spawn-commercial-plots system, bump to 0.21.0
/bns plot list|info|buy|release|reload. Single ~316-line file with
clear sections. Hardcoded PLOT_DEFS Map (3 example plots), ownership
state in server.persistentData.bnsPlots, best-effort Numismatics +
FTB Chunks integration (will be verified in playtest). See file
header for full design notes.
2026-05-23 16:01:47 +00:00
5 changed files with 349 additions and 55 deletions
+26
View File
@@ -797,6 +797,32 @@ public partial class MainWindow : Window
try try
{ {
SetBusy(true); SetBusy(true);
// ─── Refresh the Microsoft session before launch. ─────────────────
// _session was set at sign-in; the access token inside is short-lived
// (~1 hour). If the launcher's been open longer than that, MC will
// send stale credentials to the server and Mojang's session check
// rejects the join ("Invalid session"). XboxAuthNet's silent flow
// uses the cached refresh token (~14 day TTL) to mint a fresh
// access token transparently.
UpdateStatus("Refreshing session...", "");
var freshSession = await _auth.TryAuthenticateSilentlyAsync();
if (freshSession != null)
{
ApplySession(freshSession);
AppendLog("[auth] Session refreshed before launch.");
}
else
{
// Refresh token also stale -- need full interactive re-login.
// Don't try inline (player would lose context); prompt them to
// hit Sign In again.
AppendLog("[auth] Silent refresh returned no session; sign-in required.");
UpdateStatus("Session expired", "Please sign in again to continue.");
ClearSession();
return;
}
var progress = new Progress<ProgressReport>(OnProgress); var progress = new Progress<ProgressReport>(OnProgress);
var installDir = GetInstallDir(); var installDir = GetInstallDir();
_launch ??= new LaunchService(installDir); _launch ??= new LaunchService(installDir);
+6 -1
View File
@@ -4,13 +4,18 @@
<!-- net8.0-windows is required for the XboxAuthNet WebView2 OAuth flow: <!-- net8.0-windows is required for the XboxAuthNet WebView2 OAuth flow:
the netstandard2.0 build of XboxAuthNet has no WebUI implementation. --> the netstandard2.0 build of XboxAuthNet has no WebUI implementation. -->
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<!-- Allow building this Windows-targeting project from Linux/macOS hosts.
No-op on Windows. Requires the WindowsDesktop SDK files to be
present in the local .NET install (on glados copied from the
Windows .NET 8 SDK archive into /usr/lib/dotnet/). -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace> <RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName> <AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.8</Version> <Version>0.4.9</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon> <ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) --> <!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
@@ -1,53 +0,0 @@
// Brass & Sigil — JEI hides
//
// Hides all Waystones items + all Numismatics coin items from the JEI
// item list. Combined with the server-side recipe removal in
// economy_policy.js, players don't see crafting recipes OR the item
// icons in the JEI ingredient list -- they only ever encounter these
// items through the welcome grant or the bank exchange.
const COLORS = [
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
'brown', 'green', 'red', 'black',
];
const WAYSTONE_ITEMS = [
// regular waystone variants
'waystones:waystone',
'waystones:mossy_waystone',
'waystones:sandy_waystone',
'waystones:blackstone_waystone',
'waystones:deepslate_waystone',
'waystones:end_stone_waystone',
// handheld + plate
'waystones:warp_stone',
'waystones:warp_scroll',
'waystones:bound_scroll',
'waystones:return_scroll',
'waystones:warp_plate',
// sharestones (16 colors) + shards
'waystones:sharestone', // base id if registered
];
COLORS.forEach(c => {
WAYSTONE_ITEMS.push(`waystones:${c}_sharestone`);
WAYSTONE_ITEMS.push(`waystones:${c}_portstone`);
});
const COIN_ITEMS = [
'numismatics:spur',
'numismatics:bevel',
'numismatics:cog',
'numismatics:crown',
'numismatics:sun',
];
JEIEvents.hideItems(event => {
[...WAYSTONE_ITEMS, ...COIN_ITEMS].forEach(id => {
try { event.hide(id); } catch (e) {
// Some IDs may not exist (e.g. base 'waystones:sharestone' isn't
// registered if only colored variants are). Swallow per-item
// failures so one bad id doesn't break the whole pass.
}
});
});
@@ -0,0 +1,316 @@
// Brass & Sigil — Plot system V1
//
// SPAWN COMMERCIAL PLOTS (not bases). Players' homes go out in the world
// with normal FTB Chunks claims. A "plot" is a small commercial chunk-
// allocation at spawn for shops. 1 plot per player; release-and-rebuy to
// change size.
//
// Player commands (/bns plot ...):
// list show all plots + availability + price
// info <id> detailed info for one plot
// buy <id> purchase if eligible (no current plot, has coin)
// release release current plot (50% refund)
// Admin commands (OP, level 2):
// reload re-load plot definitions (currently in-script)
//
// Storage:
// Plot definitions: hardcoded `PLOT_DEFS` below; admin edits and reloads.
// Path-to-disk loading deferred to V1.1 (KubeJS file
// I/O is sandboxed).
// Ownership state: server.persistentData.bnsPlots -- compound NBT
// keyed by plot id, value = player UUID string. Single
// source of truth.
//
// Integrations (best-effort; verify in playtest):
// - Numismatics coin handling: inventory-based. Counts coins by denomination
// value, takes greedily by largest first. Assumed values:
// spur=1, bevel=8, cog=64, crown=512, sun=4096
// If Numismatics uses different ratios, adjust COIN_VALUES below.
// - FTB Chunks claim transfer: runs `/ftbchunks claim <x> <z>` as the player
// for each chunk in the plot, prefixed by `execute in <dim> as <player>`.
// Release runs `/ftbchunks unclaim`. May need adjusting if FTBC command
// syntax differs in this version.
//
// Performance:
// - All event-handler-style work is bounded by player action (command).
// No periodic ticks. No event hot-path scans.
// - Plot lookup is O(1) via Map.
// - Coin inventory scan is O(36) per purchase -- once per player action.
// ─── Plot definitions ─────────────────────────────────────────────────
// V1 hardcoded. Replace with admin-defined plots once the spawn marketplace
// is built. Chunks are [chunkX, chunkZ] pairs in the overworld.
const PLOT_DEFS = new Map([
['p001', {
id: 'p001', name: 'Market Stall 1', size: 'small',
chunks: [[10, 10]], // 1x1
price: 100, // in spurs
dimension: 'minecraft:overworld',
}],
['p002', {
id: 'p002', name: 'Corner Lot', size: 'medium',
chunks: [[12, 10], [12, 11]], // 1x2
price: 250,
dimension: 'minecraft:overworld',
}],
['p003', {
id: 'p003', name: 'Anchor Plot', size: 'large',
chunks: [[14, 10], [14, 11], [15, 10], [15, 11]], // 2x2
price: 600,
dimension: 'minecraft:overworld',
}],
]);
const REFUND_FRACTION = 0.5; // released plots refund 50% of purchase price
// ─── Numismatics coin handling ────────────────────────────────────────
const COIN_VALUES = {
'numismatics:spur': 1,
'numismatics:bevel': 8,
'numismatics:cog': 64,
'numismatics:crown': 512,
'numismatics:sun': 4096,
};
const DENOMS_DESC = ['numismatics:sun', 'numismatics:crown', 'numismatics:cog',
'numismatics:bevel', 'numismatics:spur'];
function countCoins(player) {
let total = 0;
const inv = player.inventory;
for (let i = 0; i < inv.containerSize; i++) {
const stack = inv.getItem(i);
const v = COIN_VALUES[stack.id];
if (v) total += v * stack.count;
}
return total;
}
function takeCoins(player, amount) {
let remaining = amount;
const inv = player.inventory;
// Pass 1: take from largest denomination downward (minimises change).
for (const id of DENOMS_DESC) {
const value = COIN_VALUES[id];
if (remaining < value) continue;
for (let i = 0; i < inv.containerSize && remaining >= value; i++) {
const stack = inv.getItem(i);
if (stack.id !== id) continue;
const take = Math.min(stack.count, Math.floor(remaining / value));
stack.shrink(take);
remaining -= take * value;
}
}
return amount - remaining; // returns how many spurs actually taken
}
function giveCoins(player, amount) {
// Give back as largest denomination possible to minimise inventory clutter.
let remaining = amount;
for (const id of DENOMS_DESC) {
const value = COIN_VALUES[id];
if (remaining < value) continue;
const count = Math.floor(remaining / value);
if (count > 0) {
player.give(Item.of(id, count));
remaining -= count * value;
}
}
}
// ─── Ownership storage (server.persistentData.bnsPlots compound) ──────
function getOwnerMap() {
// Lazily ensure the compound exists. The compound is a plot_id -> uuid map.
const root = Utils.server.persistentData;
if (!root.contains('bnsPlots')) {
root.put('bnsPlots', {});
}
return root.getCompound('bnsPlots');
}
function ownerOf(plotId) {
const map = getOwnerMap();
return map.contains(plotId) ? map.getString(plotId) : null;
}
function plotOwnedBy(playerUuid) {
const map = getOwnerMap();
for (const key of map.getAllKeys()) {
if (map.getString(key) === playerUuid) return key;
}
return null;
}
function recordPurchase(plotId, playerUuid) {
getOwnerMap().putString(plotId, playerUuid);
console.info(`[bns/plots] purchase recorded: ${plotId} -> ${playerUuid}`);
}
function clearOwner(plotId) {
getOwnerMap().remove(plotId);
console.info(`[bns/plots] ownership cleared: ${plotId}`);
}
// ─── FTB Chunks integration (best-effort) ────────────────────────────
// FTB Chunks doesn't expose a KubeJS API for "claim chunk X for player Y",
// but its commands (/ftbchunks claim, /ftbchunks unclaim) work as
// console-as-player. Issue them with `execute as <player>` so they claim
// to that player's team.
function claimChunksForPlayer(plot, player) {
for (const [cx, cz] of plot.chunks) {
// /ftbchunks claim claims the chunk the executor stands in by default,
// but accepts X Z args in most versions. Try with explicit coords first;
// if that fails the syntax may need a `position` subcommand variant.
const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks claim ${cx} ${cz}`;
Utils.server.runCommandSilent(cmd);
console.info(`[bns/plots] claim: ${cmd}`);
}
}
function unclaimChunksForPlayer(plot, player) {
for (const [cx, cz] of plot.chunks) {
const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks unclaim ${cx} ${cz}`;
Utils.server.runCommandSilent(cmd);
console.info(`[bns/plots] unclaim: ${cmd}`);
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
function formatPlotLine(plot) {
const owner = ownerOf(plot.id);
const status = owner ? `OWNED (${owner.substring(0,8)}...)` : 'AVAILABLE';
const cn = plot.chunks.length;
return `${plot.id} "${plot.name}" ${plot.size} ${cn} chunk${cn>1?'s':''} ${plot.price} spurs [${status}]`;
}
function sendMulti(source, lines) {
for (const line of lines) source.sendSystemMessage(Text.of(line));
}
// ─── Command tree ─────────────────────────────────────────────────────
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const plotIdArg = () => Commands.argument('id', Arguments.STRING.create(event));
const tree = Commands.literal('bns')
.then(Commands.literal('plot')
// /bns plot list
.then(Commands.literal('list').executes(ctx => {
const lines = ['§7--- Spawn plots ---'];
for (const plot of PLOT_DEFS.values()) {
lines.push(formatPlotLine(plot));
}
lines.push('§7Run §f/bns plot info <id>§7 for details or §f/bns plot buy <id>§7 to purchase.');
sendMulti(ctx.source, lines);
return 1;
}))
// /bns plot info <id>
.then(Commands.literal('info').then(plotIdArg().executes(ctx => {
const id = Arguments.STRING.getResult(ctx, 'id');
const plot = PLOT_DEFS.get(id);
if (!plot) {
ctx.source.sendSystemMessage(Text.red(`No such plot: ${id}`));
return 0;
}
const owner = ownerOf(id);
const chunkStr = plot.chunks.map(([x,z]) => `(${x},${z})`).join(', ');
sendMulti(ctx.source, [
`§6Plot ${id}: §f${plot.name}`,
`§7 Size: §f${plot.size} (${plot.chunks.length} chunk${plot.chunks.length>1?'s':''})`,
`§7 Chunks: §f${chunkStr}`,
`§7 Dimension: §f${plot.dimension}`,
`§7 Price: §f${plot.price} spurs`,
`§7 Owner: §f${owner || 'AVAILABLE'}`,
]);
return 1;
})))
// /bns plot buy <id>
.then(Commands.literal('buy').then(plotIdArg().executes(ctx => {
const player = ctx.source.player;
if (!player) {
ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
return 0;
}
const id = Arguments.STRING.getResult(ctx, 'id');
const plot = PLOT_DEFS.get(id);
if (!plot) {
player.tell(Text.red(`No such plot: ${id}`));
return 0;
}
if (ownerOf(id)) {
player.tell(Text.red(`Plot ${id} is already owned.`));
return 0;
}
const existing = plotOwnedBy(player.uuid.toString());
if (existing) {
player.tell(Text.red(
`You already own plot ${existing}. Release it first with /bns plot release.`
));
return 0;
}
const have = countCoins(player);
if (have < plot.price) {
player.tell(Text.red(
`You need ${plot.price} spurs (have ${have}). Earn coins at the bank exchange.`
));
return 0;
}
const took = takeCoins(player, plot.price);
if (took < plot.price) {
// Shouldn't happen if countCoins agreed, but be safe.
giveCoins(player, took);
player.tell(Text.red(`Coin removal failed -- refunded. Try again.`));
return 0;
}
recordPurchase(id, player.uuid.toString());
claimChunksForPlayer(plot, player);
player.tell(Text.gold(
`Plot ${id} (${plot.name}) is now yours. Paid ${plot.price} spurs.`
));
console.info(`[bns/plots] ${player.username} bought ${id} for ${plot.price} spurs`);
return 1;
})))
// /bns plot release
.then(Commands.literal('release').executes(ctx => {
const player = ctx.source.player;
if (!player) {
ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
return 0;
}
const id = plotOwnedBy(player.uuid.toString());
if (!id) {
player.tell(Text.red(`You don't own a plot.`));
return 0;
}
const plot = PLOT_DEFS.get(id);
const refund = Math.floor(plot.price * REFUND_FRACTION);
unclaimChunksForPlayer(plot, player);
clearOwner(id);
giveCoins(player, refund);
player.tell(Text.gold(
`Released plot ${id}. Refunded ${refund} spurs (${Math.round(REFUND_FRACTION*100)}% of ${plot.price}).`
));
console.info(`[bns/plots] ${player.username} released ${id}, refunded ${refund} spurs`);
return 1;
}))
// /bns plot reload (OP)
.then(Commands.literal('reload').requires(src => src.hasPermission(2))
.executes(ctx => {
// V1: PLOT_DEFS is hardcoded; reload is a no-op until V1.1
// (JSON-from-disk loading). Logged so admins know it ran.
ctx.source.sendSystemMessage(Text.gold(
`Plot defs are hardcoded in plots.js (V1). Edit the file + restart to change.`
));
console.info(`[bns/plots] reload called (no-op in V1)`);
return 1;
}))
);
event.register(tree);
console.info('[bns/plots] commands registered: list, info, buy, release, reload');
});
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing", "$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing",
"name": "Brass and Sigil", "name": "Brass and Sigil",
"version": "0.20.1", "version": "0.21.2",
"minecraft": "1.21.1", "minecraft": "1.21.1",
"loader": { "loader": {
"type": "neoforge", "type": "neoforge",