diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 20:59:07 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-05 20:59:07 +0900 |
| commit | 8396304c01dce8e0a07cdcd647dfd35293019050 (patch) | |
| tree | 66d615f6ca0b7a4bc5885cce59651d02baf9def1 | |
| parent | ce1a6123f7d48100ba3b216746127ba269fe21fb (diff) | |
| parent | 54c369939455bcfd7b66faa9d0a18aff901c05b0 (diff) | |
| download | LunaticChat-8396304c01dce8e0a07cdcd647dfd35293019050.tar.gz LunaticChat-8396304c01dce8e0a07cdcd647dfd35293019050.tar.bz2 LunaticChat-8396304c01dce8e0a07cdcd647dfd35293019050.zip | |
Merge pull request #268 from m1sk9/fix/download-and-compatibility-versions
fix: Show the actual plugin versions on the download and compatibility pages, and mark feature-short pairs
18 files changed, 490 insertions, 136 deletions
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/CompatibilityMatrix.vue b/website/.vitepress/theme/components/CompatibilityMatrix.vue index e8f5f00..0e821d0 100644 --- a/website/.vitepress/theme/components/CompatibilityMatrix.vue +++ b/website/.vitepress/theme/components/CompatibilityMatrix.vue @@ -5,7 +5,7 @@ import { useCompatibilityData, checkCompatibility, formatProtocol, - type CompatibilityResult, + olderSide, type PlatformReleaseEntry, } from './useCompatibilityData'; @@ -27,14 +27,21 @@ const t = computed(() => protocol: 'Protocol', unknown: 'unknown', compatible: 'Compatible', + degraded: 'Connects with some features unavailable', incompatible: 'Incompatible', compatibleShort: 'OK', compatibilityHeader: 'Compatibility', reasonMajorMismatch: 'Major version mismatch', reasonPaperTooNew: 'Paper protocol newer than Velocity — update Velocity first', reasonPaperTooOld: 'Paper protocol older than Velocity accepts', + reasonPaperBehind: + 'Connects, but Paper speaks an older protocol — features the newer Velocity adds are unavailable', + reasonVelocityBehind: + 'Connects, but Velocity speaks an older protocol — features the newer Paper adds are unavailable', legend: 'Legend', - legendCompatible: 'Compatible — both can connect.', + legendCompatible: 'Fully compatible — every feature is available.', + legendDegraded: + 'Connects — but features added on the newer side are unavailable.', legendIncompatible: 'Incompatible — handshake will be rejected.', } : { @@ -48,15 +55,21 @@ const t = computed(() => velocityVersion: 'Velocity バージョン', protocol: 'プロトコル', unknown: '不明', - compatible: '互換', + compatible: '完全互換', + degraded: '接続可能だが一部機能が利用不可', incompatible: '非互換', compatibleShort: 'OK', compatibilityHeader: '互換性', reasonMajorMismatch: 'MAJOR バージョン不一致', reasonPaperTooNew: 'Paper のプロトコルが Velocity より新しい — Velocity を先に更新', reasonPaperTooOld: 'Paper のプロトコルが Velocity の許容範囲より古い', + reasonPaperBehind: + '接続可能.ただし Paper のプロトコルが古いため,新しい Velocity が追加した一部機能が利用できません', + reasonVelocityBehind: + '接続可能.ただし Velocity のプロトコルが古いため,新しい Paper が追加した一部機能が利用できません', legend: '凡例', - legendCompatible: '互換 — 接続可能.', + legendCompatible: '完全互換 — 全機能が利用可能.', + legendDegraded: '接続可能 — 新しい側が追加した一部機能が利用できません.', legendIncompatible: '非互換 — ハンドシェイクで拒否されます.', }, ); @@ -79,29 +92,55 @@ function compareVersion(a: string, b: string): number { return 0; } -function reasonLabel(result: CompatibilityResult): string { +type CellState = 'ok' | 'warn' | 'ng'; + +const MARK: Record<CellState, string> = { ok: '✓', warn: '⚠', ng: '✗' }; + +interface Cell { + key: string; + state: CellState; + mark: string; + label: string; + reason: string; +} + +function cell(paper: PlatformReleaseEntry, velocity: PlatformReleaseEntry): Cell { + const base = { key: velocity.tag }; + + if (!paper.protocol || !velocity.protocol) { + return { ...base, state: 'ng', mark: MARK.ng, label: t.value.incompatible, reason: t.value.unknown }; + } + + const result = checkCompatibility(paper.protocol, velocity.protocol); switch (result) { + case 'compatible': + return { ...base, state: 'ok', mark: MARK.ok, label: t.value.compatible, reason: t.value.legendCompatible }; + case 'degraded': + return { + ...base, + state: 'warn', + mark: MARK.warn, + label: t.value.degraded, + reason: + olderSide(paper.protocol, velocity.protocol) === 'paper' + ? t.value.reasonPaperBehind + : t.value.reasonVelocityBehind, + }; case 'major-mismatch': - return t.value.reasonMajorMismatch; + return { ...base, state: 'ng', mark: MARK.ng, label: t.value.incompatible, reason: t.value.reasonMajorMismatch }; case 'paper-too-new': - return t.value.reasonPaperTooNew; + return { ...base, state: 'ng', mark: MARK.ng, label: t.value.incompatible, reason: t.value.reasonPaperTooNew }; case 'paper-too-old': - return t.value.reasonPaperTooOld; - default: - return ''; + return { ...base, state: 'ng', mark: MARK.ng, label: t.value.incompatible, reason: t.value.reasonPaperTooOld }; } } -function cellResult( - paper: PlatformReleaseEntry, - velocity: PlatformReleaseEntry, -): { ok: boolean; reason: string } { - if (!paper.protocol || !velocity.protocol) { - return { ok: false, reason: t.value.unknown }; - } - const r = checkCompatibility(paper.protocol, velocity.protocol); - return { ok: r === 'compatible', reason: r === 'compatible' ? '' : reasonLabel(r) }; -} +const rows = computed(() => + sortedPaper.value.map((paper) => ({ + paper, + cells: sortedVelocity.value.map((velocity) => cell(paper, velocity)), + })), +); </script> <template> @@ -143,21 +182,20 @@ function cellResult( <tr v-if="sortedPaper.length === 0"> <td colspan="100" class="compat-empty-cell">{{ t.emptyPaper }}</td> </tr> - <tr v-for="p in sortedPaper" :key="p.tag"> + <tr v-for="row in rows" :key="row.paper.tag"> <th scope="row" class="compat-row-header"> - <div class="compat-version">v{{ p.version }}</div> + <div class="compat-version">v{{ row.paper.version }}</div> <div class="compat-protocol"> - {{ t.protocol }}: {{ p.protocol ? formatProtocol(p.protocol) : t.unknown }} + {{ t.protocol }}: {{ row.paper.protocol ? formatProtocol(row.paper.protocol) : t.unknown }} </div> </th> <td - v-for="v in sortedVelocity" - :key="v.tag" - :class="['compat-cell', cellResult(p, v).ok ? 'compat-ok' : 'compat-ng']" - :title="cellResult(p, v).reason || t.compatible" + v-for="c in row.cells" + :key="c.key" + :class="['compat-cell', `compat-${c.state}`]" + :title="c.reason" > - <span v-if="cellResult(p, v).ok" class="compat-mark-ok" :aria-label="t.compatible">✓</span> - <span v-else class="compat-mark-ng" :aria-label="t.incompatible">✗</span> + <span :class="`compat-mark-${c.state}`" :aria-label="c.label">{{ c.mark }}</span> </td> <td v-if="sortedVelocity.length === 0" class="compat-empty-cell">{{ t.emptyVelocity }}</td> </tr> @@ -169,6 +207,7 @@ function cellResult( <p class="compat-legend-title">{{ t.legend }}</p> <ul> <li><span class="compat-mark-ok">✓</span> {{ t.legendCompatible }}</li> + <li><span class="compat-mark-warn">⚠</span> {{ t.legendDegraded }}</li> <li><span class="compat-mark-ng">✗</span> {{ t.legendIncompatible }}</li> </ul> </div> @@ -271,6 +310,10 @@ function cellResult( background: rgba(20, 200, 100, 0.08); } +.compat-warn { + background: rgba(230, 160, 30, 0.1); +} + .compat-ng { background: rgba(220, 60, 60, 0.06); } @@ -279,10 +322,20 @@ function cellResult( color: rgb(20, 160, 90); } +.compat-mark-warn { + color: rgb(176, 122, 10); +} + .compat-mark-ng { color: rgb(200, 60, 60); } +/* Amber has to lift off a dark background to stay legible, where the green and + red marks read well enough unchanged. */ +:global(.dark) .compat-mark-warn { + color: rgb(232, 179, 63); +} + .compat-empty-cell { color: var(--vp-c-text-3); font-style: italic; diff --git a/website/.vitepress/theme/components/DownloadCard.vue b/website/.vitepress/theme/components/DownloadCard.vue index add8d45..471ce68 100644 --- a/website/.vitepress/theme/components/DownloadCard.vue +++ b/website/.vitepress/theme/components/DownloadCard.vue @@ -32,7 +32,7 @@ const t = computed(() => compatLink: 'See Paper / Velocity Compatibility for details.', compatMatrixTitle: 'Compatibility Matrix', compatMatrixDesc: - 'Combinations marked ✓ can connect. Hover a cell for details.', + '✓ marks a pair where every feature is available, ⚠ a pair that connects but loses the newer side\'s additions. Hover a cell for details.', spigotNotice: 'LunaticChat only supports Paper / Folia servers. It does not work on Spigot or BungeeCord, and there are no plans to support them in the future.', spigotAlt: @@ -62,7 +62,7 @@ const t = computed(() => compatLink: '詳細は Paper / Velocity 互換性 を参照してください.', compatMatrixTitle: '互換性マトリクス', compatMatrixDesc: - '✓ の組み合わせは接続可能です.セルにホバーすると詳細が表示されます.', + '✓ は全機能が利用できる組み合わせ,⚠ は接続できるが新しい側の追加機能が使えない組み合わせです.セルにホバーすると詳細が表示されます.', spigotNotice: 'LunaticChat は Paper / Folia サーバーのみをサポートしています.Spigot / BungeeCord では動作せず,今後も対応予定はありません.', spigotAlt: @@ -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 { <dd>{{ formatSize(data.paper.fileSize) }}</dd> </div> <div> - <dd><code>{{ data.paper.fileName ?? '-' }}</code></dd> + <dd><code>{{ data.paper.fileName }}</code></dd> </div> </dl> <div class="download-actions"> - <a v-if="data.paper.downloadUrl" :href="data.paper.downloadUrl" class="download-btn primary">{{ t.download }}</a> + <a :href="data.paper.downloadUrl" class="download-btn primary">{{ t.download }}</a> <a :href="data.paper.releaseUrl" class="download-btn" target="_blank" rel="noopener">{{ t.releaseNotes }}</a> </div> </div> @@ -172,11 +170,11 @@ function formatDate(dateStr: string | null): string { <dd>{{ formatSize(data.velocity.fileSize) }}</dd> </div> <div> - <dd><code>{{ data.velocity.fileName ?? '-' }}</code></dd> + <dd><code>{{ data.velocity.fileName }}</code></dd> </div> </dl> <div class="download-actions"> - <a v-if="data.velocity.downloadUrl" :href="data.velocity.downloadUrl" class="download-btn primary">{{ t.download }}</a> + <a :href="data.velocity.downloadUrl" class="download-btn primary">{{ t.download }}</a> <a :href="data.velocity.releaseUrl" class="download-btn" target="_blank" rel="noopener">{{ t.releaseNotes }}</a> </div> </div> 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<Platform, RegExp> = { + 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<string>(); + 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.test.ts b/website/.vitepress/theme/components/useCompatibilityData.test.ts new file mode 100644 index 0000000..ec99cff --- /dev/null +++ b/website/.vitepress/theme/components/useCompatibilityData.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test'; +import { + checkCompatibility, + isCompatible, + olderSide, + type ProtocolVersion, +} from './useCompatibilityData'; + +function protocol( + major: number, + minor: number, + patch: number, + minSupportedMinor = 0, +): ProtocolVersion { + return { major, minor, patch, minSupportedMinor }; +} + +describe('checkCompatibility', () => { + test('calls an identical protocol on both ends fully compatible', () => { + expect(checkCompatibility(protocol(1, 0, 1), protocol(1, 0, 1))).toBe( + 'compatible', + ); + }); + + test('reports a degraded pair when Paper sends a sub-channel Velocity ignores', () => { + // Paper 1.3.0 speaks 1.0.1 — the PATCH that added cross-server direct + // messages — while Velocity 1.1.0 stopped at 1.0.0. + expect(checkCompatibility(protocol(1, 0, 1), protocol(1, 0, 0))).toBe( + 'degraded', + ); + }); + + test('reports a degraded pair when Velocity offers a sub-channel Paper never sends', () => { + expect(checkCompatibility(protocol(1, 0, 0), protocol(1, 0, 1))).toBe( + 'degraded', + ); + }); + + test('reports a degraded pair when Paper trails by a MINOR still inside the window', () => { + expect(checkCompatibility(protocol(1, 0, 0), protocol(1, 1, 0))).toBe( + 'degraded', + ); + }); + + test('rejects a MAJOR mismatch', () => { + expect(checkCompatibility(protocol(1, 0, 0), protocol(2, 0, 0))).toBe( + 'major-mismatch', + ); + }); + + test('rejects Paper running ahead of Velocity by a MINOR', () => { + expect(checkCompatibility(protocol(1, 1, 0), protocol(1, 0, 0))).toBe( + 'paper-too-new', + ); + }); + + test('rejects Paper older than the deprecation window admits', () => { + expect(checkCompatibility(protocol(1, 0, 0), protocol(1, 2, 0, 1))).toBe( + 'paper-too-old', + ); + }); +}); + +describe('isCompatible', () => { + test('holds for a degraded pair, which still completes the handshake', () => { + expect(isCompatible(protocol(1, 0, 1), protocol(1, 0, 0))).toBe(true); + }); + + test('fails for a pair the handshake rejects', () => { + expect(isCompatible(protocol(1, 1, 0), protocol(1, 0, 0))).toBe(false); + }); +}); + +describe('olderSide', () => { + test('names Velocity when it trails by a PATCH', () => { + expect(olderSide(protocol(1, 0, 1), protocol(1, 0, 0))).toBe('velocity'); + }); + + test('names Paper when it trails by a PATCH', () => { + expect(olderSide(protocol(1, 0, 0), protocol(1, 0, 1))).toBe('paper'); + }); + + test('lets a MINOR gap outrank the PATCH comparison', () => { + expect(olderSide(protocol(1, 0, 9), protocol(1, 1, 0))).toBe('paper'); + }); +}); diff --git a/website/.vitepress/theme/components/useCompatibilityData.ts b/website/.vitepress/theme/components/useCompatibilityData.ts index d0de01d..9468b75 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<ProtocolVersion | null> { +async function fetchProtocolAtTag( + tag: string, +): Promise<ProtocolVersion | null> { 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<ProtocolVersion | null> } } -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,9 +124,14 @@ export function useCompatibilityData() { return { data, loading, error }; } -export type CompatibilityResult = 'compatible' | 'major-mismatch' | 'paper-too-new' | 'paper-too-old'; +export type CompatibilityResult = + | 'compatible' + | 'degraded' + | 'major-mismatch' + | 'paper-too-new' + | 'paper-too-old'; -// Mirrors the gatekeeping done by Velocity in +// The first three checks mirror the gatekeeping done by Velocity in // platform-velocity/.../PluginMessageHandler.kt — Paper does not validate. export function checkCompatibility( paper: ProtocolVersion, @@ -145,6 +140,16 @@ export function checkCompatibility( if (paper.major !== velocity.major) return 'major-mismatch'; if (paper.minor > velocity.minor) return 'paper-too-new'; if (paper.minor < velocity.minSupportedMinor) return 'paper-too-old'; + + // The handshake is decided by MAJOR and MINOR alone, so what is left is how + // much of the protocol both ends speak. ProtocolVersion bumps PATCH for + // sub-channels a peer can safely ignore and MINOR for ones whose absence + // degrades behaviour, which makes any accepted difference a feature the newer + // side offers and the older one will never answer — connected, yet short of + // what the pair advertises. + if (paper.minor !== velocity.minor || paper.patch !== velocity.patch) { + return 'degraded'; + } return 'compatible'; } @@ -152,7 +157,19 @@ export function isCompatible( paper: ProtocolVersion, velocity: ProtocolVersion, ): boolean { - return checkCompatibility(paper, velocity) === 'compatible'; + const result = checkCompatibility(paper, velocity); + return result === 'compatible' || result === 'degraded'; +} + +// Which end lags the other, given the pair already connects. +export function olderSide( + paper: ProtocolVersion, + velocity: ProtocolVersion, +): 'paper' | 'velocity' { + if (paper.minor !== velocity.minor) { + return paper.minor < velocity.minor ? 'paper' : 'velocity'; + } + return paper.patch < velocity.patch ? 'paper' : 'velocity'; } export function formatProtocol(p: ProtocolVersion): string { 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/biome.jsonc b/website/biome.jsonc index 06ed29f..8e9a812 100644 --- a/website/biome.jsonc +++ b/website/biome.jsonc @@ -6,7 +6,7 @@ ".vitepress/config.mts", "biome.jsonc", ".vitepress/config/**", - ".vitepress/theme/*.ts" + ".vitepress/theme/**/*.ts" ] }, "formatter": { @@ -34,7 +34,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "suspicious": { "noShadowRestrictedNames": "off" }, 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 .", 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 <Badge type="tip" text="v1.3.0~" /> +## Cross-Server Direct Messages <Badge type="tip" text="Paper v1.3.0~ / Velocity v1.2.0~" /> > [!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 <Badge type="tip" text="v1.3.0~" /> +## Cross-Server Direct Messages <Badge type="tip" text="Paper v1.3.0~ / Velocity v1.2.0~" /> Setting `crossServerDirectMessage` to `true` lets players exchange direct messages with players on other servers connected to the same proxy. diff --git a/website/src/docs/reference/compatibility.md b/website/src/docs/reference/compatibility.md index 23274ab..2adcea6 100644 --- a/website/src/docs/reference/compatibility.md +++ b/website/src/docs/reference/compatibility.md @@ -18,7 +18,7 @@ The **plugin version** (e.g., Paper v1.2.0) and the **protocol version** (e.g., ## Compatibility Matrix -Each cell indicates whether the corresponding Paper × Velocity combination can connect. Data is fetched from GitHub Releases automatically. +Each cell indicates how far the corresponding Paper × Velocity combination works: ✓ where every feature is available, ⚠ where the pair connects but the older side cannot answer what the newer one adds, and ✗ where the handshake is rejected. Data is fetched from GitHub Releases automatically. <CompatibilityMatrix /> @@ -42,7 +42,7 @@ The rules (from Velocity's perspective) are: | Level | Example Change | Compatibility | Deployment Order | |-------|---------------|---------------|------------------| -| **PATCH** (1.0.0 → 1.0.1) | Adding optional fields, new sub-channels | Fully compatible (safe with `ignoreUnknownKeys=true`) | Any order, anytime | +| **PATCH** (1.0.0 → 1.0.1) | Adding optional fields, new sub-channels | Connects (safe with `ignoreUnknownKeys=true`), but the older peer ignores the new sub-channel, so the feature behind it stays unavailable | Any order, anytime | | **MINOR** (1.0.x → 1.1.0) | Adding required fields, changing existing sub-channel semantics | Backward compatible within `MIN_SUPPORTED_MINOR` range | **Update Velocity first** → then update each Paper server | | **MAJOR** (1.x.x → 2.0.0) | Wire format changes, removing/renaming sub-channels | Incompatible | **Simultaneous deployment of all servers** | 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` (デフォルト) である必要があります. -## クロスサーバーダイレクトメッセージ <Badge type="tip" text="v1.3.0~" /> +## クロスサーバーダイレクトメッセージ <Badge type="tip" text="Paper v1.3.0~ / Velocity v1.2.0~" /> > [!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 秒で期限切れになります.期限切れエントリを削除してもなお設定サイズを超えている場合は,残りのうち古いものから削除されます. -## クロスサーバーダイレクトメッセージ <Badge type="tip" text="v1.3.0~" /> +## クロスサーバーダイレクトメッセージ <Badge type="tip" text="Paper v1.3.0~ / Velocity v1.2.0~" /> `crossServerDirectMessage` を `true` にすると,同プロキシ内で接続しているサーバーのプレイヤー同士でメッセージのやり取りができるようになります. diff --git a/website/src/ja/docs/reference/compatibility.md b/website/src/ja/docs/reference/compatibility.md index 781dbf6..4416a26 100644 --- a/website/src/ja/docs/reference/compatibility.md +++ b/website/src/ja/docs/reference/compatibility.md @@ -18,7 +18,7 @@ LunaticChat の Paper プラグインと Velocity プラグインは独立にバ ## 互換性マトリクス -各セルは「その Paper × Velocity の組み合わせが接続できるか」を示します.データは GitHub Releases から自動取得されます. +各セルは「その Paper × Velocity の組み合わせがどこまで動作するか」を示します.✓ は全機能が利用可能,⚠ は接続できるが新しい側が追加した機能に古い側が応答できない,✗ はハンドシェイクで拒否されます.データは GitHub Releases から自動取得されます. <CompatibilityMatrix /> @@ -42,7 +42,7 @@ Paper / Velocity 間の通信は LunaticChat 独自のプラグインメッセ | レベル | 変更例 | 互換性 | デプロイ順序 | |--------|--------|--------|-------------| -| **PATCH** (1.0.0 → 1.0.1) | optional フィールド追加,新 sub-channel 追加 | 完全互換 (`ignoreUnknownKeys=true` で安全) | 順不同,いつでも | +| **PATCH** (1.0.0 → 1.0.1) | optional フィールド追加,新 sub-channel 追加 | 接続可能 (`ignoreUnknownKeys=true` で安全).ただし古い側は新 sub-channel を無視するため,その機能は利用できない | 順不同,いつでも | | **MINOR** (1.0.x → 1.1.0) | required フィールド追加,既存 sub-channel のセマンティクス変更 | `MIN_SUPPORTED_MINOR` の範囲内で後方互換 | **Velocity を先に更新** → 各 Paper を順次更新 | | **MAJOR** (1.x.x → 2.0.0) | ワイヤフォーマット変更,sub-channel 削除/リネーム | 非互換 | **全サーバー同時デプロイ** | |
