summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 20:56:07 +0900
committerSho Sakuma <me@m1sk9.dev>2026-08-05 20:56:07 +0900
commit54c369939455bcfd7b66faa9d0a18aff901c05b0 (patch)
tree66d615f6ca0b7a4bc5885cce59651d02baf9def1
parent1a7b6ec2586db0663f09924721db71f3f2e1eadc (diff)
downloadLunaticChat-54c369939455bcfd7b66faa9d0a18aff901c05b0.tar.gz
LunaticChat-54c369939455bcfd7b66faa9d0a18aff901c05b0.tar.bz2
LunaticChat-54c369939455bcfd7b66faa9d0a18aff901c05b0.zip
feat: warn where a pair connects but falls short of a feature
The matrix marked every accepted pair with a plain tick, which reads as "everything works". It does not. ProtocolVersion bumps PATCH for sub-channels a peer can safely ignore, so Paper 1.3.0 (protocol 1.0.1) against Velocity 1.1.0 (1.0.0) completes the handshake and then silently drops cross-server direct messages — the very feature that PATCH was bumped for. An operator reading the tick had no way to learn that, and the pages around it repeated the claim. The handshake is settled by MAJOR and MINOR alone, so any difference left once a pair is accepted is a feature the newer end offers and the older will never answer. Those pairs now carry a warning that says which end lags. Naming the feature would take a protocol-version-to-feature table on the website, which would drift from the protocol it describes, so the warning stays general and leaves the reader one hop from the compatibility page. The handshake verdict keeps the three checks that mirror Velocity's gate, with the new one layered after them, so the mirror stays honest. isCompatible now holds for a degraded pair, which does connect. Cells are built once per pair in a computed instead of recomputing on each of the template's reads, which a third state would otherwise have multiplied. Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--website/.vitepress/theme/components/CompatibilityMatrix.vue111
-rw-r--r--website/.vitepress/theme/components/DownloadCard.vue4
-rw-r--r--website/.vitepress/theme/components/useCompatibilityData.test.ts86
-rw-r--r--website/.vitepress/theme/components/useCompatibilityData.ts27
-rw-r--r--website/src/docs/reference/compatibility.md4
-rw-r--r--website/src/ja/docs/reference/compatibility.md4
6 files changed, 199 insertions, 37 deletions
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 046de21..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:
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 2952355..9468b75 100644
--- a/website/.vitepress/theme/components/useCompatibilityData.ts
+++ b/website/.vitepress/theme/components/useCompatibilityData.ts
@@ -126,11 +126,12 @@ export function useCompatibilityData() {
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,
@@ -139,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';
}
@@ -146,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/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/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 削除/リネーム | 非互換 | **全サーバー同時デプロイ** |