From af776eac4fd971483397f40e8c8452df485892e0 Mon Sep 17 00:00:00 2001 From: Sho Sakuma Date: Wed, 5 Aug 2026 19:46:00 +0900 Subject: fix: read plugin versions from release JAR names, not tag names The download cards and the compatibility matrix derived each plugin's version from the release tag, which cannot hold for a unified `vX.Y.Z` release: it carries both JARs and their versions need not agree. v1.3.0 shipped Paper 1.3.0 alongside Velocity 1.2.0. So the download page advertised Paper v1.2.2 and Velocity v1.1.0, two generations stale, because it searched `paper/v` and `velocity/v` tags first and fell back to a unified tag only when none existed. The matrix meanwhile put a Velocity v1.3.0 on its axis that was never released, leaving no row for the proxy build operators actually run. Both now pick releases by the JARs attached to them and take the version from the file name, the only place it is stated. Tag shape stops mattering: a platform-specific tag and a unified one are alike just releases that happen to carry a given JAR. Releases rank by publication time rather than by API response order, which follows tag creation instead: velocity/v1.1.0 precedes the later-published paper/v1.2.2. A version is listed once even when a later unified release re-attaches an unchanged JAR, which release.yaml does whenever only the other platform was bumped. Otherwise the matrix would carry one version twice, under two protocol versions if the protocol had moved in between. Tests run in CI from here on. They need an explicit path because Bun does not discover tests inside dot-directories. Co-Authored-By: Claude --- .github/workflows/ci.yaml | 3 + .../.vitepress/theme/components/DownloadCard.vue | 14 +- .../theme/components/releaseAssets.test.ts | 144 +++++++++++++++++++++ .../.vitepress/theme/components/releaseAssets.ts | 78 +++++++++++ .../theme/components/useCompatibilityData.ts | 60 ++++----- .../.vitepress/theme/components/useDownloadData.ts | 70 ++++------ website/package.json | 1 + 7 files changed, 281 insertions(+), 89 deletions(-) create mode 100644 website/.vitepress/theme/components/releaseAssets.test.ts create mode 100644 website/.vitepress/theme/components/releaseAssets.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 28e2dfb..16e4b50 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -118,6 +118,9 @@ jobs: - name: Check lints run: bun run lint + - name: Run tests + run: bun run test + - name: Build documentation run: bun run build diff --git a/website/.vitepress/theme/components/DownloadCard.vue b/website/.vitepress/theme/components/DownloadCard.vue index add8d45..046de21 100644 --- a/website/.vitepress/theme/components/DownloadCard.vue +++ b/website/.vitepress/theme/components/DownloadCard.vue @@ -71,14 +71,12 @@ const t = computed(() => }, ); -function formatSize(bytes: number | null): string { - if (!bytes) return '-'; +function formatSize(bytes: number): string { const mb = bytes / (1024 * 1024); return `${mb.toFixed(1)} MB`; } -function formatDate(dateStr: string | null): string { - if (!dateStr) return '-'; +function formatDate(dateStr: string): string { const locale = isEn.value ? 'en-US' : 'ja-JP'; return new Date(dateStr).toLocaleDateString(locale, { year: 'numeric', @@ -138,11 +136,11 @@ function formatDate(dateStr: string | null): string {
{{ formatSize(data.paper.fileSize) }}
-
{{ data.paper.fileName ?? '-' }}
+
{{ data.paper.fileName }}
@@ -172,11 +170,11 @@ function formatDate(dateStr: string | null): string {
{{ formatSize(data.velocity.fileSize) }}
-
{{ data.velocity.fileName ?? '-' }}
+
{{ data.velocity.fileName }}
diff --git a/website/.vitepress/theme/components/releaseAssets.test.ts b/website/.vitepress/theme/components/releaseAssets.test.ts new file mode 100644 index 0000000..32dea7b --- /dev/null +++ b/website/.vitepress/theme/components/releaseAssets.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { + findPlatformAsset, + type GitHubRelease, + latestPlatformRelease, + platformReleases, +} from './releaseAssets'; + +function release( + tag: string, + publishedAt: string, + assetNames: string[], +): GitHubRelease { + return { + tag_name: tag, + published_at: publishedAt, + html_url: `https://github.com/m1sk9/LunaticChat/releases/tag/${tag}`, + assets: assetNames.map((name) => ({ + name, + size: 1024, + browser_download_url: `https://example.invalid/${name}`, + })), + }; +} + +const UNIFIED_1_3_0 = release('v1.3.0', '2026-08-04T20:05:51Z', [ + 'LunaticChat-1.2.0-velocity.jar', + 'LunaticChat-1.3.0.jar', +]); +const PAPER_1_2_2 = release('paper/v1.2.2', '2026-06-12T07:57:55Z', [ + 'LunaticChat-1.2.2.jar', +]); +const VELOCITY_1_1_0 = release('velocity/v1.1.0', '2026-06-12T07:57:33Z', [ + 'LunaticChat-1.1.0-velocity.jar', +]); + +describe('findPlatformAsset', () => { + test('reads each platform version from its own JAR, not from the shared tag', () => { + expect(findPlatformAsset(UNIFIED_1_3_0, 'paper')?.version).toBe('1.3.0'); + expect(findPlatformAsset(UNIFIED_1_3_0, 'velocity')?.version).toBe('1.2.0'); + }); + + test('returns the asset backing the reported version', () => { + expect(findPlatformAsset(UNIFIED_1_3_0, 'velocity')?.asset.name).toBe( + 'LunaticChat-1.2.0-velocity.jar', + ); + }); + + test('does not mistake the Velocity JAR for the Paper JAR', () => { + expect(findPlatformAsset(VELOCITY_1_1_0, 'paper')).toBeNull(); + }); + + test('returns null when the release carries no JAR for the platform', () => { + expect(findPlatformAsset(PAPER_1_2_2, 'velocity')).toBeNull(); + }); +}); + +describe('latestPlatformRelease', () => { + const releases = [UNIFIED_1_3_0, VELOCITY_1_1_0, PAPER_1_2_2]; + + test('prefers a newer unified release over an older platform-specific tag', () => { + expect(latestPlatformRelease(releases, 'paper')?.version).toBe('1.3.0'); + expect(latestPlatformRelease(releases, 'velocity')?.version).toBe('1.2.0'); + }); + + test('prefers a newer platform-specific tag over an older unified release', () => { + const paperOnly = release('paper/v1.4.0', '2026-09-01T00:00:00Z', [ + 'LunaticChat-1.4.0.jar', + ]); + const withPaperOnly = [...releases, paperOnly]; + + expect(latestPlatformRelease(withPaperOnly, 'paper')?.version).toBe( + '1.4.0', + ); + expect(latestPlatformRelease(withPaperOnly, 'velocity')?.version).toBe( + '1.2.0', + ); + }); + + test('advances one platform without disturbing the other', () => { + const velocityOnly = release('velocity/v1.3.0', '2026-09-01T00:00:00Z', [ + 'LunaticChat-1.3.0-velocity.jar', + ]); + const withVelocityOnly = [...releases, velocityOnly]; + + expect(latestPlatformRelease(withVelocityOnly, 'velocity')?.version).toBe( + '1.3.0', + ); + expect(latestPlatformRelease(withVelocityOnly, 'paper')?.version).toBe( + '1.3.0', + ); + }); + + test('ranks by publication time rather than by response order', () => { + const outOfOrder = [PAPER_1_2_2, UNIFIED_1_3_0]; + expect(latestPlatformRelease(outOfOrder, 'paper')?.release.tag_name).toBe( + 'v1.3.0', + ); + }); + + test('returns null when no release carries a JAR for the platform', () => { + expect(latestPlatformRelease([PAPER_1_2_2], 'velocity')).toBeNull(); + }); +}); + +describe('platformReleases', () => { + test('lists every release carrying the platform JAR, newest first', () => { + expect( + platformReleases([PAPER_1_2_2, UNIFIED_1_3_0], 'paper').map( + (e) => e.version, + ), + ).toEqual(['1.3.0', '1.2.2']); + }); + + test('excludes releases that ship only the other platform', () => { + expect(platformReleases([VELOCITY_1_1_0], 'paper')).toEqual([]); + }); + + test('lists a version once when a later unified release re-attaches its JAR', () => { + // v1.4.0 bumps Paper alone, so it re-attaches the unchanged Velocity 1.2.0 JAR. + const unified_1_4_0 = release('v1.4.0', '2026-09-01T00:00:00Z', [ + 'LunaticChat-1.2.0-velocity.jar', + 'LunaticChat-1.4.0.jar', + ]); + + expect( + platformReleases([UNIFIED_1_3_0, unified_1_4_0], 'velocity').map( + (e) => e.version, + ), + ).toEqual(['1.2.0']); + }); + + test('attributes a re-attached JAR to the release that shipped it last', () => { + const unified_1_4_0 = release('v1.4.0', '2026-09-01T00:00:00Z', [ + 'LunaticChat-1.2.0-velocity.jar', + 'LunaticChat-1.4.0.jar', + ]); + + expect( + platformReleases([UNIFIED_1_3_0, unified_1_4_0], 'velocity')[0]?.release + .tag_name, + ).toBe('v1.4.0'); + }); +}); diff --git a/website/.vitepress/theme/components/releaseAssets.ts b/website/.vitepress/theme/components/releaseAssets.ts new file mode 100644 index 0000000..f05977b --- /dev/null +++ b/website/.vitepress/theme/components/releaseAssets.ts @@ -0,0 +1,78 @@ +export type Platform = 'paper' | 'velocity'; + +export interface ReleaseAsset { + name: string; + size: number; + browser_download_url: string; +} + +export interface GitHubRelease { + tag_name: string; + published_at: string; + html_url: string; + assets: ReleaseAsset[]; +} + +export interface PlatformAsset { + version: string; + asset: ReleaseAsset; +} + +export interface PlatformRelease extends PlatformAsset { + release: GitHubRelease; +} + +// A unified `vX.Y.Z` tag ships both platforms and their versions need not agree +// (v1.3.0 carried Velocity 1.2.0), so a tag name can never stand in for a +// platform version — only the JAR file name states it. +const JAR_PATTERN: Record = { + paper: /^LunaticChat-(\d+\.\d+\.\d+)\.jar$/, + velocity: /^LunaticChat-(\d+\.\d+\.\d+)-velocity\.jar$/, +}; + +export function findPlatformAsset( + release: GitHubRelease, + platform: Platform, +): PlatformAsset | null { + for (const asset of release.assets) { + const version = JAR_PATTERN[platform].exec(asset.name)?.[1]; + if (version) return { version, asset }; + } + return null; +} + +export function platformReleases( + releases: GitHubRelease[], + platform: Platform, +): PlatformRelease[] { + // The API orders releases by tag creation, not by publication — velocity/v1.1.0 + // precedes the later-published paper/v1.2.2 — so response order cannot decide + // which release is the newest. + const found = releases + .flatMap((release) => { + const asset = findPlatformAsset(release, platform); + return asset ? [{ release, ...asset }] : []; + }) + .sort( + (a, b) => + Date.parse(b.release.published_at) - Date.parse(a.release.published_at), + ); + + // A unified `vX.Y.Z` release always attaches both JARs, so a platform version + // reappears under a new tag whenever only the other platform was bumped. The + // newest publication is the one that shipped, and keeping both would list the + // same version twice — with two protocol versions when the protocol moved. + const seen = new Set(); + return found.filter((entry) => { + if (seen.has(entry.version)) return false; + seen.add(entry.version); + return true; + }); +} + +export function latestPlatformRelease( + releases: GitHubRelease[], + platform: Platform, +): PlatformRelease | null { + return platformReleases(releases, platform)[0] ?? null; +} diff --git a/website/.vitepress/theme/components/useCompatibilityData.ts b/website/.vitepress/theme/components/useCompatibilityData.ts index d0de01d..2952355 100644 --- a/website/.vitepress/theme/components/useCompatibilityData.ts +++ b/website/.vitepress/theme/components/useCompatibilityData.ts @@ -1,15 +1,14 @@ -import { ref, onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; +import { + type GitHubRelease, + type Platform, + platformReleases, +} from './releaseAssets'; const REPO = 'm1sk9/LunaticChat'; const PROTOCOL_FILE_PATH = 'engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt'; -interface GitHubRelease { - tag_name: string; - published_at: string; - html_url: string; -} - export interface ProtocolVersion { major: number; minor: number; @@ -18,7 +17,7 @@ export interface ProtocolVersion { } export interface PlatformReleaseEntry { - platform: 'paper' | 'velocity'; + platform: Platform; version: string; tag: string; publishedAt: string; @@ -55,7 +54,9 @@ function parseProtocolVersion(source: string): ProtocolVersion | null { return { major, minor, patch, minSupportedMinor }; } -async function fetchProtocolAtTag(tag: string): Promise { +async function fetchProtocolAtTag( + tag: string, +): Promise { const url = `https://raw.githubusercontent.com/${REPO}/${encodeURIComponent(tag)}/${PROTOCOL_FILE_PATH}`; try { const res = await fetch(url); @@ -67,19 +68,18 @@ async function fetchProtocolAtTag(tag: string): Promise } } -function buildEntry( - release: GitHubRelease, - platform: 'paper' | 'velocity', -): PlatformReleaseEntry { - const version = release.tag_name.replace(/^(paper\/|velocity\/)?v/, ''); - return { +function buildEntries( + releases: GitHubRelease[], + platform: Platform, +): PlatformReleaseEntry[] { + return platformReleases(releases, platform).map((entry) => ({ platform, - version, - tag: release.tag_name, - publishedAt: release.published_at, - releaseUrl: release.html_url, + version: entry.version, + tag: entry.release.tag_name, + publishedAt: entry.release.published_at, + releaseUrl: entry.release.html_url, protocol: null, - }; + })); } export function useCompatibilityData() { @@ -99,18 +99,8 @@ export function useCompatibilityData() { const releases: GitHubRelease[] = await res.json(); - const isUnified = (tag: string) => /^v\d/.test(tag); - const isPaperTag = (tag: string) => - tag.startsWith('paper/v') || isUnified(tag); - const isVelocityTag = (tag: string) => - tag.startsWith('velocity/v') || isUnified(tag); - - const paper = releases - .filter((r) => isPaperTag(r.tag_name)) - .map((r) => buildEntry(r, 'paper')); - const velocity = releases - .filter((r) => isVelocityTag(r.tag_name)) - .map((r) => buildEntry(r, 'velocity')); + const paper = buildEntries(releases, 'paper'); + const velocity = buildEntries(releases, 'velocity'); const all = [...paper, ...velocity]; const protocols = await Promise.all( @@ -134,7 +124,11 @@ export function useCompatibilityData() { return { data, loading, error }; } -export type CompatibilityResult = 'compatible' | 'major-mismatch' | 'paper-too-new' | 'paper-too-old'; +export type CompatibilityResult = + | 'compatible' + | 'major-mismatch' + | 'paper-too-new' + | 'paper-too-old'; // Mirrors the gatekeeping done by Velocity in // platform-velocity/.../PluginMessageHandler.kt — Paper does not validate. diff --git a/website/.vitepress/theme/components/useDownloadData.ts b/website/.vitepress/theme/components/useDownloadData.ts index 0c9e7e0..8ed264a 100644 --- a/website/.vitepress/theme/components/useDownloadData.ts +++ b/website/.vitepress/theme/components/useDownloadData.ts @@ -1,27 +1,19 @@ -import { ref, onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; +import { + type GitHubRelease, + latestPlatformRelease, + type Platform, +} from './releaseAssets'; const REPO = 'm1sk9/LunaticChat'; -interface ReleaseAsset { - name: string; - size: number; - browser_download_url: string; -} - -interface GitHubRelease { - tag_name: string; - published_at: string; - html_url: string; - assets: ReleaseAsset[]; -} - export interface PlatformRelease { version: string; publishedAt: string; releaseUrl: string; - downloadUrl: string | null; - fileName: string | null; - fileSize: number | null; + downloadUrl: string; + fileName: string; + fileSize: number; } export interface DownloadData { @@ -30,22 +22,20 @@ export interface DownloadData { ci: { url: string }; } -function parsePlatformRelease( - release: GitHubRelease | null, - jarPattern: RegExp, +function resolvePlatformRelease( + releases: GitHubRelease[], + platform: Platform, ): PlatformRelease | null { - if (!release) return null; - - const asset = release.assets.find((a) => jarPattern.test(a.name)); - const version = release.tag_name.replace(/^(paper\/|velocity\/)?v/, ''); + const latest = latestPlatformRelease(releases, platform); + if (!latest) return null; return { - version, - publishedAt: release.published_at, - releaseUrl: release.html_url, - downloadUrl: asset?.browser_download_url ?? null, - fileName: asset?.name ?? null, - fileSize: asset?.size ?? null, + version: latest.version, + publishedAt: latest.release.published_at, + releaseUrl: latest.release.html_url, + downloadUrl: latest.asset.browser_download_url, + fileName: latest.asset.name, + fileSize: latest.asset.size, }; } @@ -72,25 +62,9 @@ export function useDownloadData() { const releases: GitHubRelease[] = await res.json(); - const paperRelease = - releases.find((r) => r.tag_name.startsWith('paper/v')) ?? - releases.find((r) => /^v\d/.test(r.tag_name)) ?? - null; - - const velocityRelease = - releases.find((r) => r.tag_name.startsWith('velocity/v')) ?? - releases.find((r) => /^v\d/.test(r.tag_name)) ?? - null; - data.value = { - paper: parsePlatformRelease( - paperRelease, - /^LunaticChat-[\d.]+\.jar$/, - ), - velocity: parsePlatformRelease( - velocityRelease, - /^LunaticChat-[\d.]+-velocity\.jar$/, - ), + paper: resolvePlatformRelease(releases, 'paper'), + velocity: resolvePlatformRelease(releases, 'velocity'), ci: data.value.ci, }; } catch { diff --git a/website/package.json b/website/package.json index 48c2a6b..5b18155 100644 --- a/website/package.json +++ b/website/package.json @@ -3,6 +3,7 @@ "dev": "vitepress dev", "build": "vitepress build", "preview": "vitepress preview", + "test": "bun test ./.vitepress/", "format": "biome format --write .", "format:check": "biome ci .", "lint": "biome lint .", -- cgit v1.2.1 From 639173db48d037ffa5c084d089e5fc7d85d71d80 Mon Sep 17 00:00:00 2001 From: Sho Sakuma Date: Wed, 5 Aug 2026 19:46:16 +0900 Subject: docs: name both plugin versions for cross-server direct messages The badge read `v1.3.0~`, pointing at a Velocity release that does not exist. The feature needs both halves, and the Velocity half first shipped as 1.2.0 inside the unified v1.3.0 release, so a reader hunting for Velocity v1.3.0 finds nothing and cannot tell which proxy build carries the relay. Also refreshes the gradle.properties example, which still showed the versions current when it was written. Co-Authored-By: Claude --- website/src/docs/developers/resource.md | 4 ++-- website/src/docs/features/direct-message.md | 2 +- website/src/docs/features/velocity.md | 2 +- website/src/ja/docs/developers/resource.md | 4 ++-- website/src/ja/docs/features/direct-message.md | 2 +- website/src/ja/docs/features/velocity.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/website/src/docs/developers/resource.md b/website/src/docs/developers/resource.md index 1526cf7..543365d 100644 --- a/website/src/docs/developers/resource.md +++ b/website/src/docs/developers/resource.md @@ -20,8 +20,8 @@ Both platforms' `processResources` compute `version` / `gitCommitHash` / `channe ```properties # gradle.properties -paperVersion=1.2.2 -velocityVersion=1.1.0 +paperVersion=1.3.0 +velocityVersion=1.2.0 ``` Paper and Velocity carry separate version numbers and can be released independently. That's because **compatibility is guaranteed by the engine-shared [`ProtocolVersion`](/docs/developers/engine#versioning-strategy-protocolversion) rather than the numeric version**, so the two platforms — which change at different rates — can be bumped and published at their own pace. The wire format is forward-compatible via JSON + `ignoreUnknownKeys`, and backward compatibility is controlled by matching MAJOR + a MINOR-range check on the protocol. diff --git a/website/src/docs/features/direct-message.md b/website/src/docs/features/direct-message.md index ec0f24e..e799985 100644 --- a/website/src/docs/features/direct-message.md +++ b/website/src/docs/features/direct-message.md @@ -30,7 +30,7 @@ Replies to the last player who sent you a message. If there is no such player, t To use quick reply, `features.quickReplies.enabled` must be `true` (default) in `config.yml`. -## Cross-Server Direct Messages +## Cross-Server Direct Messages > [!NOTE] > diff --git a/website/src/docs/features/velocity.md b/website/src/docs/features/velocity.md index 7842a24..f9d3814 100644 --- a/website/src/docs/features/velocity.md +++ b/website/src/docs/features/velocity.md @@ -53,7 +53,7 @@ Each message is assigned a unique ID, and a cache prevents the same message from Entries expire 60 seconds after they are recorded. If the cache is still over its configured size after expired entries are cleared, the oldest remaining entries are dropped. -## Cross-Server Direct Messages +## Cross-Server Direct Messages Setting `crossServerDirectMessage` to `true` lets players exchange direct messages with players on other servers connected to the same proxy. diff --git a/website/src/ja/docs/developers/resource.md b/website/src/ja/docs/developers/resource.md index b06864a..0eea858 100644 --- a/website/src/ja/docs/developers/resource.md +++ b/website/src/ja/docs/developers/resource.md @@ -20,8 +20,8 @@ Gradle マルチモジュール構成で,engine を共有しつつ Paper / Vel ```properties # gradle.properties -paperVersion=1.2.2 -velocityVersion=1.1.0 +paperVersion=1.3.0 +velocityVersion=1.2.0 ``` Paper と Velocity は別々のバージョン番号を持ち,独立にリリースできます.**互換性を数値バージョンではなく engine 共有の [`ProtocolVersion`](/ja/docs/developers/engine#バージョニング戦略-protocolversion) で保証している**ため,更新頻度の異なる 2 プラットフォームをそれぞれのペースでバンプ・公開できるからです.ワイヤ形式は JSON + `ignoreUnknownKeys` で前方互換,プロトコルの MAJOR 一致 + MINOR 範囲チェックで後方互換をコントロールします. diff --git a/website/src/ja/docs/features/direct-message.md b/website/src/ja/docs/features/direct-message.md index 736a257..400d1d6 100644 --- a/website/src/ja/docs/features/direct-message.md +++ b/website/src/ja/docs/features/direct-message.md @@ -30,7 +30,7 @@ layout: doc クイック返信を利用するには `config.yml` で `features.quickReplies.enabled` が `true` (デフォルト) である必要があります. -## クロスサーバーダイレクトメッセージ +## クロスサーバーダイレクトメッセージ > [!NOTE] > diff --git a/website/src/ja/docs/features/velocity.md b/website/src/ja/docs/features/velocity.md index 9020ea1..70c34fd 100644 --- a/website/src/ja/docs/features/velocity.md +++ b/website/src/ja/docs/features/velocity.md @@ -53,7 +53,7 @@ features: エントリは記録から 60 秒で期限切れになります.期限切れエントリを削除してもなお設定サイズを超えている場合は,残りのうち古いものから削除されます. -## クロスサーバーダイレクトメッセージ +## クロスサーバーダイレクトメッセージ `crossServerDirectMessage` を `true` にすると,同プロキシ内で接続しているサーバーのプレイヤー同士でメッセージのやり取りができるようになります. -- cgit v1.2.1 From adf547883cee87a6706f8190bc845a5a94de2768 Mon Sep 17 00:00:00 2001 From: Sho Sakuma Date: Wed, 5 Aug 2026 19:46:22 +0900 Subject: chore: put the theme's composables under Biome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `includes` stopped at `.vitepress/theme/*.ts`, so everything under `components/` went unchecked — including the release and compatibility logic this branch touches. Widening it to `**/*.ts` takes the check from five files to nine. `.vue` stays out. Biome does not resolve `