diff --git a/.env.example b/.env.example
index f6ac0ab..e8dd0da 100644
--- a/.env.example
+++ b/.env.example
@@ -5,9 +5,13 @@
# 線上部署:Dashboard → Settings → Variables and Secrets,金鑰請選 Secret(加密)。
# ══ 賽道一之一:敵方 AI agent「遺忘者」 ══════════════════════════
-# 每回合讀盤下決策的那個對手。預設走 OpenAI。
-# 想讓敵方 agent 也跑在 0G Compute 上,把這行改成 AI_PROVIDER=0g 即可,程式碼不用動。
-AI_PROVIDER=openai
+# 每回合讀盤下決策的那個對手。**預設跑在 0G Compute 上**。
+#
+# 為什麼不是 OpenAI:要主張「整場七回合都是同一位 agent」,那個 agent 就必須跑在
+# enclave 裡並帶回可驗證的簽名。跑在 OpenAI 上的話 TEE 只是掛在旁白那一段,
+# 跟玩家實際對打的對手無關,那個主張就不成立。
+# 想切回 OpenAI(例如想要更低延遲)就設成 openai —— 但「同一位 agent」的驗證會降級。
+AI_PROVIDER=0g
OPENAI_API_KEY=sk-your-openai-key
OPENAI_MODEL=gpt-4o-mini
# 走 Azure OpenAI 或自架相容端點時才需要改
@@ -20,6 +24,14 @@ OG_COMPUTE_API_KEY=sk-your-0g-router-key
OG_COMPUTE_MODEL=deepseek-chat-v3-0324
# OG_COMPUTE_BASE_URL=https://router-api.0g.ai/v1
+# provider 的 attestation(遠端證明)端點。
+#
+# 這是「同一位 agent」證明鏈的第一環:報告裡有 enclave 的 measurement(程式碼與模型
+# 的雜湊)與一把 enclave 內生成的公鑰。有它才能主張「每回合的簽名確實出自這個 enclave」,
+# 而不只是「出自某把金鑰」。沒設的話畫面會照實顯示「未取得 attestation」並降一級,
+# 不會假裝驗過。
+OG_COMPUTE_ATTESTATION_URL=
+
# ══ 賽道二:0G Storage ══════════════════════════════════════════
# 上傳預設由前端用玩家自己的錢包完成(public/js/storage.js + 官方 SDK),
# 不需要在這裡設任何東西,伺服器也不碰私鑰。
diff --git a/README.md b/README.md
index d42de2f..4ae11ef 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,9 @@ curl "https://conssswars-web.pages.dev/api/og/storage?root=0x<你的root>" | jq
# 賽道三:拿任何一筆錨定交易的 hash 回來重驗
curl "https://conssswars-web.pages.dev/api/og/verify?tx=0x<你的交易hash>" | jq
+
+# 賽道一:provider 的 attestation(enclave measurement 與公鑰)
+curl https://conssswars-web.pages.dev/api/og/attest | jq
```
---
@@ -113,8 +116,9 @@ inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔
- 三條「記憶迴廊」,每條 3 格。你在左、遺忘者在右。
- 每回合 **2 點算力** —— 顧不了三條,**選哪條放掉就是勝負**。
-- 交戰**之前**先比火力:一條迴廊誰的攻擊力總和高,就啃對方核心 **2 點**。
+- 交戰**之前**先比火力:一條迴廊贏多少就啃多少 —— 多 **1~2 攻**啃 1 點,多 **3 攻以上**啃滿 2 點,打平不啃。
單位當回合陣亡也算數,所以「投入多少」比「殺幾隻」重要。
+- **雙方同時出牌。** AI agent 拿到的是你部署**之前**的盤面 —— 它看不到你這回合放了什麼。
- 貼身或迎面撞上的單位互砍;打贏還活著的會乘勝追擊繼續前進。
- 走出邊界的單位直接打核心。核心 12 點,共 7 回合,先歸零的一方輸。
@@ -127,9 +131,50 @@ inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔
npm run sim # 跑 300 場隨機對戰,印出勝負分布與平均回合數
```
-> ⚠️ **改任何數值前請先重跑模擬。** 現行數值是調出來的:合理玩法約第 6 回合收掉,
-> 亂打大多會輸。模擬用的是隨機出牌,每次結果會抖動 —— 連跑五輪的實測範圍是
-> **落敗 59–69%、平均 3.7–3.8 回合**(其餘多為和局,隨機亂打贏面約兩成)。
+> ⚠️ **改任何數值前請先重跑模擬。** `npm run sim` 用隨機出牌,每次會抖動;連跑三輪的
+> 實測範圍是**勝 30–33%、敗 46–54%、平均 4.3–4.4 回合**。
+>
+> 另外用「雙方都下最好的一手、各 25% 機率失誤」的模型測過對局平衡:
+> **勝 34% / 敗 40% / 和 27%,平均核心差 −0.7**。這兩個數字是這樣調出來的 ——
+> 詳見下面。
+
+
+為什麼壓制傷害是遞增的,以及為什麼要同時出牌
+
+原本的規則有兩個問題,模擬下來**對上會下棋的對手,玩家勝率是 0%**:
+
+1. **火力性價比不對稱。** 壓制傷害只看攻擊力總和,所以真正的貨幣是「每點算力換到多少
+ 攻擊力」。憶哨兵原本 1 費 2 攻,遺忘者的雜訊是 1 費 3 攻 —— 同樣的算力,它每條迴廊
+ 都比你高一截。這不是操作問題,是算術問題。
+2. **AI 是後手,而且看得到你這回合的部署。** 在「逐條迴廊比火力」的規則下後手幾乎必勝:
+ 只要在你投重兵的那條放掉,另外兩條各補一點就淨賺。
+
+而原本的壓制是**贏者全拿**(多 1 點攻擊力就直接拿滿 2 點),導致平衡是離散的 ——
+把哨兵調到 3 攻,結果直接從「必輸」跳成「**100% 和局**」(雙方火力永遠打平 → 誰都不受傷)。
+
+所以三件事要一起改:
+
+| 改動 | 作用 |
+| --- | --- |
+| 壓制傷害改成 `min(2, ⌈差/2⌉)` | 讓平衡從離散變連續,數值才調得動;上限仍是 2 點,沒有任何迴廊變得更危險 |
+| 憶哨兵 2 → 3 攻 | 補上算術劣勢 |
+| 改成同時出牌 | 拔掉 AI 的後手優勢 |
+
+實測(每組 300 場,雙方各 25% 機率失誤):
+
+| 設定 | 勝 / 敗 / 和 | 核心差 |
+| --- | --- | --- |
+| 原本 | 6% / 84% / 10% | −7.4 |
+| 只改成同時出牌 | 8% / 86% / 6% | −7.8 |
+| 只改傷害公式 | 3% / 85% / 12% | −8.3 |
+| 傷害公式 + 同時出牌 | 5% / 89% / 6% | −10.1 |
+| **三個一起改** | **34% / 40% / 27%** | **−0.7** |
+
+單獨改任何一項都沒用 —— 遞增傷害不是解方,而是**讓數值可以被調整的前提**。
+
+雙方都下得更好時(失誤率 10%),同時出牌的價值才明顯:玩家勝率 29% → **37%**,
+和局率 35% → 28%。
+
---
@@ -226,11 +271,12 @@ API 金鑰請選 **Secret**(加密),不要用一般變數。
| 變數 | 賽道 | 必要性 | 說明 |
| --- | --- | --- | --- |
-| `OPENAI_API_KEY` | 敵方 agent | 建議設 | OpenAI 金鑰。**只存在 Function 端**,不會進前端 bundle。 |
-| `OPENAI_MODEL` | 敵方 agent | 選用 | 預設 `gpt-4o-mini` |
-| `AI_PROVIDER` | 敵方 agent | 選用 | `openai`(預設)或 `0g`(敵方 agent 也切到 0G Compute) |
-| `OG_COMPUTE_API_KEY` | 賽道一 | 建議設 | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得 |
+| `OG_COMPUTE_API_KEY` | 賽道一 | **建議設** | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得。敵方 agent 與旁白 agent 都吃這把 |
| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `deepseek-chat-v3-0324` |
+| `OG_COMPUTE_ATTESTATION_URL` | 賽道一 | 想驗到 enclave 等級才需要 | provider 的 attestation 端點。沒設的話「同一位 agent」最多只能驗到 `signed`(同一把金鑰),畫面會照實降級顯示 |
+| `AI_PROVIDER` | 敵方 agent | 選用 | `0g`(**預設**)或 `openai`。切回 OpenAI 會讓「同一位 agent」的驗證降級 |
+| `OPENAI_API_KEY` | 敵方 agent | 只有切回 OpenAI 才要 | **只存在 Function 端**,不會進前端 bundle |
+| `OPENAI_MODEL` | 敵方 agent | 選用 | 預設 `gpt-4o-mini` |
| `OG_STORAGE_INDEXER` | 賽道二 | 選用 | 預設 Turbo indexer;唯讀查詢不需金鑰 |
| `OG_ZG_PROXY_BASE` | 賽道二 | **上傳必要** | 節點代理的網址(結尾要有 `/api`)。沒設的話前端會走同源的 `/api/og/zg`,那條路在 Cloudflare 上必定失敗 —— 見上面「賽道二還需要一台 Node 代理」 |
| `OG_PROXY_SECRET` | 賽道二 | 選用 | 代理用來簽節點網址的金鑰。擋的是「有人拿這個網域當跳板」,不是機密資料;沒設會用內建常數。要更嚴的話**主站與 proxy 兩邊要設成同一個值** |
diff --git a/functions/api/agent.js b/functions/api/agent.js
index 1be468e..8479d64 100644
--- a/functions/api/agent.js
+++ b/functions/api/agent.js
@@ -12,6 +12,8 @@
* 前端 rules.js 還會再 validate 一次。台詞用 textContent 塞進 DOM,不走 innerHTML。
*/
+import { sealTurn } from './og/tee.js';
+
const OPENAI_BASE = 'https://api.openai.com/v1';
const OG_ROUTER_BASE = 'https://router-api.0g.ai/v1';
@@ -28,8 +30,16 @@ const json = (data, status = 200) =>
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
+/**
+ * 預設 **0G Compute**,設 AI_PROVIDER=openai 才切回 OpenAI。
+ *
+ * 一開始是反過來的(預設 OpenAI,理由是每回合都要叫、要低延遲)。但那讓「TEE」
+ * 變成空話:跟玩家對打的 agent 根本不在 enclave 裡,就無從證明「整場都是同一位」。
+ * 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名。
+ * 沒設金鑰時仍會退回本地啟發式,並照實標示「未接上模型」。
+ */
function providerConfig(env) {
- const choice = String(env.AI_PROVIDER || 'openai').toLowerCase();
+ const choice = String(env.AI_PROVIDER || '0g').toLowerCase();
if (choice === '0g' || choice === '0g-compute') {
return {
id: '0g-compute',
@@ -66,8 +76,13 @@ const SYSTEM = `你是回合制策略遊戲《鏈州英雄傳 ConSSS Wars》裡
- VOID 虛數殼:cost 2,攻 2 血 7。很難殺,適合卡住迴廊。
- PURGE 清算:cost 3,法術,對指定迴廊的所有敵方單位造成 3 傷,並回復自己核心 3 點。
-重要:每回合交戰「之前」會先比較每條迴廊雙方的總攻擊力,高的一方對敵方核心造成 2 點壓制傷害。
-所以「在哪條迴廊投入多少火力」比單純殺兵更重要 —— 即使單位當回合就陣亡,投入的火力一樣算數。
+重要:每回合交戰「之前」會先比較每條迴廊雙方的總攻擊力,高的一方對敵方核心造成壓制傷害。
+傷害隨差距遞增:多 1~2 攻造成 1 點,多 3 攻以上造成 2 點(上限 2),打平不造成傷害。
+所以「在哪條迴廊投入多少火力」比單純殺兵更重要 —— 即使單位當回合就陣亡,投入的火力一樣算數;
+而且贏一條迴廊要贏得夠多才拿得滿,勉強超過只拿一半。
+
+重要:你和敵人是「同時出牌」。你看到的盤面是敵人這回合部署「之前」的狀態,
+所以你不知道他這回合會放什麼、放在哪一條。請照他過去的習慣與盤面威脅去推測,不要假設某條迴廊一定空著。
策略提示:敵方單位推進到 cell 1 或 2 時要優先處理;三條迴廊的火力都要顧,被壓制的迴廊每回合都在扣血;算力沒用完等於浪費。
@@ -231,12 +246,31 @@ export async function onRequestPost({ request, env }) {
return fallback(`${cfg.label} ${res.status}: ${detail.slice(0, 160)}`);
}
- const data = await res.json();
+ // 原始文字要留著:證據鏈裡的 responseHash 算的是「供應商實際回了什麼」,
+ // 不是我們解析後的結果 —— 解析後才算就等於在證明我們自己的程式,沒有意義。
+ const rawText = await res.text();
+ let data = null;
+ try {
+ data = JSON.parse(rawText);
+ } catch {
+ return fallback(`${cfg.label} 回傳不是 JSON`);
+ }
const message = data && data.choices && data.choices[0] && data.choices[0].message;
const parsed = extractJson(message && message.content);
if (!parsed) return fallback(`${cfg.label} 回傳無法解析`);
const plays = sanitizePlays(parsed.plays, legal, view.yourCompute);
+ const evidence = await sealTurn({
+ turn: Number.isFinite(Number(view.turn)) ? Math.max(1, Math.min(99, Math.trunc(Number(view.turn)))) : 1,
+ view,
+ legal,
+ rawResponse: rawText,
+ headers: res.headers,
+ body: data,
+ model: cfg.model,
+ provider: cfg.id,
+ });
+
return json({
plays: plays.length ? plays : heuristic(view, legal),
usedFallbackPlays: plays.length === 0,
@@ -245,6 +279,7 @@ export async function onRequestPost({ request, env }) {
provider: cfg.id,
providerLabel: cfg.label,
model: cfg.model,
+ evidence,
});
} catch (err) {
return fallback(
diff --git a/functions/api/og/_shared.js b/functions/api/og/_shared.js
index 4fe76db..2323c23 100644
--- a/functions/api/og/_shared.js
+++ b/functions/api/og/_shared.js
@@ -65,9 +65,16 @@ export async function rpc(url, method, params = [], timeoutMs = 6000) {
export const rpcUrlOf = (env) => env.OG_RPC_URL || GALILEO.rpcUrl;
export const indexerOf = (env) => env.OG_STORAGE_INDEXER || STORAGE_INDEXER;
-/** 敵方 agent 的供應商。預設 OpenAI;AI_PROVIDER=0g 就整支切到 0G Compute。 */
+/**
+ * 敵方 agent 的供應商。**預設 0G Compute**,設 AI_PROVIDER=openai 才會切回 OpenAI。
+ *
+ * 一開始是反過來的(預設 OpenAI,理由是低延遲)。但那讓「TEE」這件事變成空話:
+ * 跟玩家對打的 agent 根本不在 enclave 裡,也就無從證明「整場都是同一位」。
+ * 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名,
+ * 所以預設改過來。沒設金鑰時仍會退回本地啟發式,並照實標示。
+ */
export function providerConfig(env) {
- const choice = String(env.AI_PROVIDER || 'openai').toLowerCase();
+ const choice = String(env.AI_PROVIDER || '0g').toLowerCase();
if (choice === '0g' || choice === '0g-compute') return ogComputeConfig(env);
return {
id: 'openai',
diff --git a/functions/api/og/attest.js b/functions/api/og/attest.js
new file mode 100644
index 0000000..e4b6244
--- /dev/null
+++ b/functions/api/og/attest.js
@@ -0,0 +1,83 @@
+/**
+ * GET /api/og/attest —— 取得 0G Compute provider 的 attestation(遠端證明)。
+ *
+ * 這是「同一位 agent」證明鏈的第一環:attestation 報告裡有 enclave 的 measurement
+ * (程式碼與模型的雜湊)與一把 enclave 內生成的公鑰,由硬體廠商與 0G 的驗證服務背書。
+ * 有了它,才能主張「後面每回合的簽名確實出自這個 enclave」,而不只是「出自某把金鑰」。
+ *
+ * ── 為什麼要吃環境變數 ──
+ *
+ * attestation 的實際端點與回應格式,我們沒有辦法在建置環境裡打一次確認
+ * (*.0g.ai 在那裡連不到)。與其猜一個網址寫死、然後在評審面前 404,
+ * 不如讓它可設定:OG_COMPUTE_ATTESTATION_URL 指到哪就打哪。
+ *
+ * 沒設定時**照實回報沒設定**,不編造。前端會把它顯示成「未取得 attestation」,
+ * 而不是綠燈 —— 這個功能的價值完全建立在「不說謊」上面。
+ */
+
+import { json, ogComputeConfig } from './_shared.js';
+import { extractEvidence } from './tee.js';
+
+const TIMEOUT_MS = 8000;
+
+export async function onRequestGet({ env }) {
+ const cfg = ogComputeConfig(env);
+ const url = env.OG_COMPUTE_ATTESTATION_URL;
+
+ if (!url) {
+ return json({
+ configured: false,
+ endpoint: null,
+ model: cfg.model,
+ router: cfg.base,
+ error:
+ '未設定 OG_COMPUTE_ATTESTATION_URL。沒有 attestation 就無法證明簽章金鑰長在 enclave 裡 —— ' +
+ '這種情況下最多只能證明「所有回合出自同一把金鑰」,畫面會照實標示。',
+ });
+ }
+
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(url, {
+ signal: ac.signal,
+ headers: {
+ accept: 'application/json',
+ ...(cfg.key ? { authorization: `Bearer ${cfg.key}` } : {}),
+ },
+ });
+ const text = await res.text();
+ let body = null;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = { raw: text.slice(0, 600) };
+ }
+ if (!res.ok) {
+ return json({ configured: true, endpoint: url, ok: false, status: res.status, error: text.slice(0, 200) });
+ }
+
+ // 欄位名不押寶,跟每回合的簽名用同一套容忍抽取
+ const found = extractEvidence(res.headers, body);
+ return json({
+ configured: true,
+ endpoint: url,
+ ok: Boolean(found.measurement || found.quote),
+ measurement: found.measurement || null,
+ publicKey: found.signer || null,
+ quotePresent: Boolean(found.quote),
+ model: cfg.model,
+ router: cfg.base,
+ raw: body,
+ });
+ } catch (err) {
+ return json({
+ configured: true,
+ endpoint: url,
+ ok: false,
+ error: String(err && err.name === 'AbortError' ? '逾時' : (err && err.message) || err).slice(0, 200),
+ });
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/functions/api/og/same-agent.js b/functions/api/og/same-agent.js
new file mode 100644
index 0000000..483cd0d
--- /dev/null
+++ b/functions/api/og/same-agent.js
@@ -0,0 +1,62 @@
+/**
+ * POST /api/og/same-agent —— 驗證整場對戰是不是同一位 AI agent。
+ *
+ * 收下前端累積的每回合證據鏈(盤面雜湊、回應雜湊、簽名、簽章公鑰、模型),
+ * 加上 /api/og/attest 拿到的 attestation,回報能證明到什麼程度。
+ *
+ * 這支刻意**不會**回傳單純的 true/false。能證明到哪一層取決於供應商給了什麼材料,
+ * 把「只是紀錄一致」講成「已驗證」是這整個功能最容易犯、也最不該犯的錯。
+ * 分級的定義見 tee.js 的 verifyChain。
+ */
+
+import { json, clampStr, clampInt } from './_shared.js';
+import { verifyChain } from './tee.js';
+
+const MAX_BODY = 32 * 1024;
+const MAX_TURNS = 12;
+
+/** 只留白名單欄位,長度夾住 —— 這些字串會直接進畫面。 */
+const normalizeTurn = (t) => ({
+ turn: clampInt(t && t.turn, 1, 99, 1),
+ boardHash: clampStr(t && t.boardHash, 70),
+ responseHash: clampStr(t && t.responseHash, 70),
+ signature: clampStr(t && t.signature, 400) || null,
+ signer: clampStr(t && t.signer, 200) || null,
+ measurement: clampStr(t && t.measurement, 200) || null,
+ proofId: clampStr(t && t.proofId, 120) || null,
+ model: clampStr(t && t.model, 60) || null,
+ provider: clampStr(t && t.provider, 40) || null,
+});
+
+export async function onRequestPost({ request }) {
+ let payload;
+ try {
+ const raw = await request.text();
+ if (raw.length > MAX_BODY) return json({ error: 'payload too large' }, 413);
+ payload = JSON.parse(raw);
+ } catch {
+ return json({ error: 'invalid json' }, 400);
+ }
+
+ const chain = Array.isArray(payload && payload.chain)
+ ? payload.chain.slice(0, MAX_TURNS).map(normalizeTurn)
+ : [];
+ const attestation = payload && typeof payload.attestation === 'object' ? payload.attestation : null;
+
+ const verdict = verifyChain(chain, attestation);
+ return json({
+ ...verdict,
+ // 逐回合攤開,讓畫面可以一格一格點亮,而不是只給一句結論
+ perTurn: chain.map((t) => ({
+ turn: t.turn,
+ boardHash: t.boardHash,
+ responseHash: t.responseHash,
+ signed: Boolean(t.signature),
+ signer: t.signer,
+ model: t.model,
+ sameAsFirst: !t.signer || !chain[0].signer ? null : t.signer === chain[0].signer,
+ })),
+ });
+}
+
+export const onRequestGet = () => json({ error: 'use POST' }, 405);
diff --git a/functions/api/og/shard.js b/functions/api/og/shard.js
index 12ae97c..48331bd 100644
--- a/functions/api/og/shard.js
+++ b/functions/api/og/shard.js
@@ -52,6 +52,29 @@ function normalize(raw) {
taunt: clampStr(t && t.taunt, 60),
}))
: [],
+
+ /*
+ * 每回合的 TEE 證據鏈。
+ *
+ * 這一段的存在理由是:碎片的 SHA-256 會被錨定到 0G Chain,所以把證據鏈封進碎片
+ * 之後,「整場七回合都是同一位 agent」這件事就被鏈上摘要蓋住了 —— 第三方拿著
+ * 鏈上那筆交易,就能重新驗證整場,而不是只能相信我們的伺服器。
+ *
+ * 只存雜湊與簽名,不存盤面原文與模型回覆全文:碎片要小(要付儲存費),
+ * 而雜湊已經足以綁定內容。簽名長度夾在 400 字內,避免單一欄位撐爆碎片。
+ */
+ teeChain: Array.isArray(raw && raw.teeChain)
+ ? raw.teeChain.slice(0, 12).map((t) => ({
+ turn: clampInt(t && t.turn, 1, 99, 1),
+ boardHash: clampStr(t && t.boardHash, 70),
+ responseHash: clampStr(t && t.responseHash, 70),
+ signature: clampStr(t && t.signature, 400),
+ signer: clampStr(t && t.signer, 200),
+ measurement: clampStr(t && t.measurement, 200),
+ model: clampStr(t && t.model, 60),
+ provider: clampStr(t && t.provider, 40),
+ }))
+ : [],
createdAt: new Date().toISOString(),
};
}
diff --git a/functions/api/og/tee.js b/functions/api/og/tee.js
new file mode 100644
index 0000000..904fcb1
--- /dev/null
+++ b/functions/api/og/tee.js
@@ -0,0 +1,176 @@
+/**
+ * TEE 證據的抽取與驗證。
+ *
+ * 我們想證明的一件事:**跟你對打的 AI agent,整場七回合都是同一位,中途沒被換掉。**
+ *
+ * 0G Compute 的做法是:provider 啟動時在 enclave 內生成一組簽章金鑰,私鑰永遠不出
+ * enclave;CPU/GPU 的 attestation 報告把那把公鑰綁定到這個 enclave 環境(含程式碼與
+ * 模型的 measurement)。之後每一次推論的回應都用那把 enclave 私鑰簽名。
+ *
+ * 於是「同一位」的證明就是:
+ * 1. 每回合的回應都有簽名,且
+ * 2. 所有簽名都能用同一把公鑰驗過,且
+ * 3. 那把公鑰綁定的 measurement 從頭到尾沒變。
+ *
+ * ── 這支為什麼寫得這麼「容忍」──
+ *
+ * 0G 把驗證材料放在回應的哪個欄位、標頭叫什麼名字,我們沒有辦法在開發環境裡實際打一次
+ * 確認(docs.0g.ai 在我們的建置環境連不到)。所以這裡不押寶在單一欄位名上:
+ * 標頭與 JSON 樹都走訪一遍,看到像簽名 / 公鑰 / measurement 的鍵就收下來。
+ *
+ * 最重要的原則:**抽不到就回報抽不到**。寧可讓畫面顯示「供應商沒有回傳簽名」,
+ * 也不要因為欄位名猜錯就假裝驗過了 —— 那比沒有這個功能更糟。
+ */
+
+import { sha256Hex } from './_shared.js';
+
+/** 這些鍵名任一出現(不分大小寫、忽略底線與連字號)就當成該類材料。 */
+const KEYS = {
+ signature: ['signature', 'sig', 'responsesignature', 'teesignature', 'ogsignature', 'proofsignature'],
+ signer: ['signeraddress', 'signingaddress', 'signer', 'publickey', 'pubkey', 'signingkey', 'teepublickey', 'providerpubkey'],
+ measurement: ['measurement', 'mrenclave', 'mrsigner', 'enclavemeasurement', 'codehash', 'imagehash', 'rtmr'],
+ quote: ['quote', 'attestation', 'attestationreport', 'rareport', 'teequote', 'evidence'],
+ proofId: ['proofid', 'proof', 'requestid', 'inferenceid'],
+};
+
+const norm = (k) => String(k).toLowerCase().replace(/[-_\s]/g, '');
+
+/** 值要像個「證據」:夠長的字串或 hex,不是 true/1 這種佔位。 */
+function usable(v) {
+ if (typeof v === 'string') return v.trim().length >= 8;
+ if (v && typeof v === 'object') return Object.keys(v).length > 0;
+ return false;
+}
+
+const shorten = (v) => {
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
+ return s.length > 400 ? s.slice(0, 400) + '…' : s;
+};
+
+/**
+ * 走訪整棵 JSON 樹 + 回應標頭,把各類材料撈出來。
+ * 同一類撈到多個時取第一個 —— 巢狀越淺的越可能是正主,所以用廣度優先。
+ */
+export function extractEvidence(headers, body) {
+ const found = {};
+ const take = (kind, value) => {
+ if (!found[kind] && usable(value)) found[kind] = typeof value === 'string' ? value.trim() : value;
+ };
+
+ if (headers && typeof headers.forEach === 'function') {
+ headers.forEach((value, name) => {
+ const n = norm(name).replace(/^x/, '');
+ for (const [kind, names] of Object.entries(KEYS)) {
+ if (names.some((k) => n === k || n.endsWith(k))) take(kind, value);
+ }
+ });
+ }
+
+ const queue = [body];
+ let guard = 0;
+ while (queue.length && guard++ < 500) {
+ const node = queue.shift();
+ if (!node || typeof node !== 'object') continue;
+ if (Array.isArray(node)) { queue.push(...node.slice(0, 32)); continue; }
+ for (const [k, v] of Object.entries(node)) {
+ const n = norm(k);
+ for (const [kind, names] of Object.entries(KEYS)) {
+ if (names.includes(n)) take(kind, v);
+ }
+ if (v && typeof v === 'object') queue.push(v);
+ }
+ }
+ return found;
+}
+
+/** 這一回合的證據封包。boardHash 綁住「它看到的盤面」,responseHash 綁住「它說了什麼」。 */
+export async function sealTurn({ turn, view, legal, rawResponse, headers, body, model, provider }) {
+ const evidence = extractEvidence(headers, body);
+ const enc = new TextEncoder();
+ return {
+ turn,
+ // 盤面雜湊用送出去的原始內容算,之後可以重算比對,證明沒有被事後修改
+ boardHash: await sha256Hex(enc.encode(JSON.stringify({ board: view, legalPlays: legal }))),
+ responseHash: await sha256Hex(enc.encode(String(rawResponse || ''))),
+ model: model || null,
+ provider: provider || null,
+ signature: evidence.signature ? shorten(evidence.signature) : null,
+ signer: evidence.signer ? shorten(evidence.signer) : null,
+ measurement: evidence.measurement ? shorten(evidence.measurement) : null,
+ proofId: evidence.proofId ? shorten(evidence.proofId) : null,
+ };
+}
+
+/**
+ * 驗一整場:所有回合是不是同一位 agent。
+ *
+ * 回傳的 level 是刻意分級的,因為能證明到什麼程度取決於供應商給了什麼:
+ *
+ * attested 有簽名、同一把公鑰,而且公鑰對得上 attestation 的 measurement
+ * → 這才是「enclave 等級」的證明
+ * signed 有簽名、同一把公鑰,但拿不到 attestation
+ * → 證明所有回應出自同一把金鑰,但沒證明那把金鑰真的長在 enclave 裡
+ * changed 有簽名,但公鑰中途換過
+ * → 這是**否定**的結果,不是中性的。它正是「agent 被換掉」的樣子,
+ * 必須顯眼地講出來,不能跟「沒拿到材料」混為一談
+ * consistent 沒有(或只有部分)簽名,只有「每回合供應商與模型都相同」
+ * → 這只是我們自己伺服器的紀錄,你得相信我們。不是密碼學證明。
+ * none 連一回合的紀錄都沒有,或供應商/模型本身就換過
+ *
+ * 前端必須照這個分級顯示,不可以把 consistent 畫成綠燈。
+ */
+export function verifyChain(chain, attestation) {
+ const turns = Array.isArray(chain) ? chain : [];
+ if (turns.length === 0) return { level: 'none', ok: false, turns: 0, reason: '沒有任何回合的紀錄' };
+
+ const signers = new Set(turns.map((t) => t.signer).filter(Boolean));
+ const models = new Set(turns.map((t) => t.model).filter(Boolean));
+ const providers = new Set(turns.map((t) => t.provider).filter(Boolean));
+ const signed = turns.filter((t) => t.signature).length;
+
+ const sameModel = models.size <= 1;
+ const sameProvider = providers.size <= 1;
+ const sameSigner = signers.size === 1;
+ const allSigned = signed === turns.length;
+
+ const attestedKey = attestation && (attestation.publicKey || attestation.signer) || null;
+ const keyMatches =
+ Boolean(attestedKey) && sameSigner && [...signers][0]
+ ? String([...signers][0]).toLowerCase().includes(String(attestedKey).toLowerCase().slice(0, 16))
+ : false;
+
+ let level = 'none';
+ if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested';
+ else if (allSigned && sameSigner) level = 'signed';
+ else if (signed > 0 && signers.size > 1) level = 'changed';
+ else if (sameModel && sameProvider) level = 'consistent';
+
+ const noAttest = attestation && attestation.error ? `(${attestation.error})` : '';
+ const reason =
+ level === 'attested'
+ ? '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement'
+ : level === 'signed'
+ ? `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${noAttest},無法證明那把金鑰長在 enclave 裡`
+ : level === 'changed'
+ ? `簽章公鑰中途換過(這 ${turns.length} 個回合出現了 ${signers.size} 把不同的公鑰)—— 這正是「agent 被換掉」的樣子,不能主張是同一位`
+ : level === 'consistent'
+ ? signed === 0
+ ? '供應商沒有回傳任何可驗證的簽名。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明'
+ : `只有 ${signed}/${turns.length} 個回合帶回簽名,其餘沒有,不足以主張整場都被簽過。供應商與模型本身是一致的,但那只是我們伺服器的紀錄`
+ : '每回合的供應商或模型不一致,無法主張是同一位 agent';
+
+ return {
+ level,
+ ok: level === 'attested',
+ turns: turns.length,
+ signedTurns: signed,
+ sameSigner,
+ sameModel,
+ sameProvider,
+ signer: sameSigner ? [...signers][0] : null,
+ models: [...models],
+ providers: [...providers],
+ measurement: (attestation && attestation.measurement) || null,
+ reason,
+ };
+}
diff --git a/public/css/style.css b/public/css/style.css
index 18aabdf..9057e2b 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1229,3 +1229,100 @@ body {
white-space: nowrap;
border: 0;
}
+
+/*
+ * TEE 驗證面板。
+ *
+ * 燈號刻意分四級而不是「通過/不通過」:能證明到哪一層取決於供應商回傳了什麼。
+ * 把「只是紀錄一致」畫成綠燈,比沒有這個功能更糟 —— 所以顏色由 data-level 決定,
+ * 只有 attested(有簽名、同一把公鑰、對得上 measurement)才拿得到藍燈。
+ */
+.tee {
+ margin-top: 12px;
+ border: 2px solid var(--ink);
+ background: var(--paper);
+ padding: 10px 12px 12px;
+}
+
+.tee-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.tee-title {
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+}
+
+.tee-badge {
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 700;
+ padding: 2px 7px;
+ border: 1.5px solid var(--ink);
+ white-space: nowrap;
+}
+
+.tee-badge[data-level='attested'] { background: var(--blue); color: #fff; border-color: var(--blue); }
+.tee-badge[data-level='signed'] { background: #F2C36B; color: var(--ink); }
+.tee-badge[data-level='consistent'] { background: var(--paper-edge); color: var(--muted); }
+.tee-badge[data-level='changed'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); }
+.tee-badge[data-level='none'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); }
+
+.tee-reason {
+ margin: 0 0 8px;
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--muted);
+}
+
+/* 每回合一格。玩家要一眼看出「七回合都是同一盞燈」。 */
+.tee-turns {
+ list-style: none;
+ margin: 0 0 8px;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.tee-turn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ border: 1.5px solid var(--ink);
+ padding: 3px 7px;
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.tee-turn-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--paper-edge);
+ border: 1.5px solid var(--ink);
+ flex: none;
+}
+
+.tee-turn[data-state='ok'] .tee-turn-dot { background: var(--blue); border-color: var(--blue); }
+.tee-turn[data-state='changed'] .tee-turn-dot { background: var(--terracotta); border-color: var(--terracotta); }
+.tee-turn[data-state='unsigned'] .tee-turn-dot { background: transparent; }
+
+.tee-meta {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 10px;
+ line-height: 1.7;
+ color: var(--muted);
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 2px 10px;
+}
+
+.tee-meta dt { color: var(--ink); }
+.tee-meta dd { margin: 0; overflow-wrap: anywhere; }
diff --git a/public/index.html b/public/index.html
index 7027c87..cf052a9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -183,8 +183,29 @@
>在 0G Storage 上查看 ↗
+
+
+
+
+
+
+
+
+ TEE 驗證 · 同一位 agent
+
+
+
+
+
+
+
diff --git a/public/js/ai.js b/public/js/ai.js
index 9c82d4d..4b4f5f9 100644
--- a/public/js/ai.js
+++ b/public/js/ai.js
@@ -56,6 +56,9 @@ export async function askAgent(state, summary = '') {
providerLabel: data.providerLabel || data.provider || '未知',
model: data.model || null,
note: data.note || null,
+ // 這一回合的 TEE 證據(盤面雜湊、回應雜湊、簽名、簽章公鑰)。
+ // 供應商沒回傳可驗證的材料時會是 null —— 前端必須照實顯示,不可以當成通過。
+ evidence: data.evidence || null,
};
} catch {
return offline;
diff --git a/public/js/main.js b/public/js/main.js
index 4688f7d..dab775a 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -48,6 +48,9 @@ const game = {
busy: false,
agentInfo: { providerLabel: '尚未呼叫', provider: null, model: null },
agentTurns: [],
+ agentPending: null,
+ teeChain: [],
+ teeVerdict: null,
ogStatus: null,
wallet: null,
shard: null,
@@ -201,11 +204,14 @@ function startBattle() {
game.selected = null;
game.busy = false;
game.agentTurns = [];
+ game.teeChain = [];
+ game.teeVerdict = null;
game.shard = null;
el.taunt.classList.add('is-empty');
- setAgentChip('AI agent 待命');
+ game.agentPending = null;
show('battle');
render();
+ beginAgentThinking();
}
function setAgentChip(text, thinking = false) {
@@ -367,6 +373,33 @@ function playSelected(lane) {
render();
}
+/**
+ * 同時出牌:回合一開始就用「玩家還沒部署」的盤面去問 agent。
+ *
+ * 原本是等玩家按下結束回合、才把當前盤面送過去 —— 那等於它拿到你的答案卷才作答。
+ * 在「逐條迴廊比火力」的規則下後手幾乎必勝:只要在你投重兵的那條放掉,
+ * 另外兩條各補一點就淨賺。模擬顯示對上會下棋的對手,玩家勝率是 0%。
+ *
+ * 改成回合開始就送出,還有一個附帶好處:它的思考時間跟你的重疊,回合更順。
+ * 它從舊盤面挑的手可能已經不合法(例如你用溯憶術清掉了它的出兵格附近),
+ * applyAgentPlays 每一手都會 validate,不合法的自動略過 —— 這正是戰爭迷霧該有的樣子。
+ */
+function beginAgentThinking() {
+ const snapshot = R.cloneState(game.state);
+ const summary = `第 ${snapshot.turn} 回合,我方核心 ${snapshot.core.forgetter},敵方核心 ${snapshot.core.hero}`;
+ setAgentChip('AI agent 讀盤中…', true);
+ // 刻意不 await:讓它在玩家思考的同時決策
+ game.agentPending = askAgent(snapshot, summary).catch((err) => ({
+ plays: [],
+ taunt: '',
+ reason: '',
+ providerLabel: '本地備援',
+ provider: null,
+ model: null,
+ error: String((err && err.message) || err),
+ }));
+}
+
async function endTurn() {
if (game.busy || game.state.over) return;
game.busy = true;
@@ -374,14 +407,16 @@ async function endTurn() {
render();
// ── 遺忘者(AI agent)回合 ──
- setAgentChip('AI agent 讀盤中…', true);
+ // 它在你部署之前就開始想了,這裡只是等它回來
sfx.agent();
- const summary = `第 ${game.state.turn} 回合,我方核心 ${game.state.core.forgetter},敵方核心 ${game.state.core.hero}`;
- const decision = await askAgent(game.state, summary);
+ const decision = await (game.agentPending || Promise.resolve({ plays: [], taunt: '', reason: '', providerLabel: '本地備援' }));
+ game.agentPending = null;
game.agentInfo = decision;
const { applied } = applyAgentPlays(game.state, decision.plays, R.applyPlay);
game.agentTurns.push({ turn: game.state.turn, plays: applied, taunt: decision.taunt });
+ // TEE 證據鏈:有拿到才收,沒拿到就不收 —— 鏈的長度短於回合數本身就是一種訊號
+ if (decision.evidence) game.teeChain.push(decision.evidence);
const modelTag = decision.model ? ` · ${decision.model}` : '';
setAgentChip(
@@ -417,6 +452,7 @@ async function endTurn() {
el.trayHint.textContent = '選一張牌,再點一條迴廊部署。';
game.busy = false;
render();
+ beginAgentThinking(); // 新回合:它跟你同時開始想
}
/** 動畫刻意壓在 1 秒內:一分鐘的遊戲不能把時間花在等特效。 */
@@ -591,6 +627,7 @@ async function buildShard(result) {
narrator: narrator ? narrator.provider : '',
narration: narrator ? narrator.narration : '',
agentTurns: game.agentTurns,
+ teeChain: game.teeChain,
});
game.shard = data;
$('shard-digest').textContent = data.digest;
@@ -842,6 +879,104 @@ async function pollStorage(root, note, attempts = 6) {
return false;
}
+/**
+ * 賽道一的收尾:驗證整場對戰是不是同一位 AI agent。
+ *
+ * 這跟「鏈上回驗」驗的不是同一件事 —— 那個驗資料有沒有真的上鏈,這個驗
+ * 「跟你對打的對手中途有沒有被換掉」。做法是把每回合的證據(它看到的盤面雜湊、
+ * 它回應的雜湊、enclave 簽名、簽章公鑰)串起來,看是不是同一把公鑰、
+ * 而且那把公鑰對得上 attestation 裡的 enclave measurement。
+ *
+ * 結果分四級,刻意不做成通過/不通過:能證明到哪一層完全取決於供應商回了什麼。
+ * 把「只是紀錄一致」畫成綠燈,比沒有這個功能更糟。
+ */
+const TEE_BADGE = {
+ attested: '已驗證 · enclave 等級',
+ signed: '部分驗證 · 缺 attestation',
+ changed: '⚠ 公鑰中途換過',
+ consistent: '未驗證 · 僅紀錄一致',
+ none: '無法驗證',
+};
+
+async function verifyTee() {
+ const btn = $('btn-tee');
+ const panel = $('tee');
+ const reason = $('tee-reason');
+ btn.disabled = true;
+ btn.textContent = '驗證中…';
+
+ try {
+ // attestation 拿不到不算失敗 —— 它只是決定最高能驗到哪一級
+ const attestation = await fetch('/api/og/attest', { headers: { accept: 'application/json' } })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null);
+
+ const res = await fetch('/api/og/same-agent', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ chain: game.teeChain, attestation }),
+ });
+ if (!res.ok) throw new Error(`same-agent ${res.status}`);
+ const v = await res.json();
+ game.teeVerdict = v;
+
+ const badge = $('tee-badge');
+ badge.textContent = TEE_BADGE[v.level] || v.level;
+ badge.dataset.level = v.level;
+ reason.textContent = v.reason;
+
+ // 逐回合亮燈:同一把公鑰=藍、換過=橘、沒簽名=空心
+ const list = $('tee-turns');
+ list.textContent = '';
+ for (const t of v.perTurn || []) {
+ const li = document.createElement('li');
+ li.className = 'tee-turn';
+ li.dataset.state = !t.signed ? 'unsigned' : t.sameAsFirst === false ? 'changed' : 'ok';
+ const dot = document.createElement('span');
+ dot.className = 'tee-turn-dot';
+ const label = document.createElement('span');
+ label.textContent = `第 ${t.turn} 回合`;
+ li.append(dot, label);
+ list.appendChild(li);
+ }
+ if (!(v.perTurn || []).length) {
+ const li = document.createElement('li');
+ li.className = 'tee-turn';
+ li.dataset.state = 'unsigned';
+ li.textContent = '這一場沒有任何回合留下證據';
+ list.appendChild(li);
+ }
+
+ const meta = $('tee-meta');
+ meta.textContent = '';
+ const rows = [
+ ['回合數', `${v.signedTurns} / ${v.turns} 有簽名`],
+ ['模型', (v.models || []).join(' / ') || '—'],
+ ['供應商', (v.providers || []).join(' / ') || '—'],
+ ['簽章公鑰', v.signer || '未取得'],
+ ['enclave measurement', v.measurement || '未取得'],
+ ];
+ for (const [k, val] of rows) {
+ const dt = document.createElement('dt');
+ dt.textContent = k;
+ const dd = document.createElement('dd');
+ dd.textContent = val;
+ meta.append(dt, dd);
+ }
+
+ panel.hidden = false;
+ btn.textContent = '重新驗證';
+ } catch (err) {
+ panel.hidden = false;
+ $('tee-badge').textContent = '驗證失敗';
+ $('tee-badge').dataset.level = 'none';
+ reason.textContent = `驗證請求本身失敗:${String((err && err.message) || err).slice(0, 120)}`;
+ btn.textContent = 'TEE 驗證 · 是同一位 agent 嗎';
+ } finally {
+ btn.disabled = false;
+ }
+}
+
/**
* 賽道三的收尾:把剛送出去的交易從 0G Chain 讀回來,重新解析 calldata 並比對摘要。
* 「錢包沒報錯」不算證明,讀得回來、摘要對得上才算。
@@ -861,6 +996,8 @@ async function verifyAnchor({ quiet = false } = {}) {
btn.textContent = '鏈上回驗';
btn.disabled = false;
$('proof').hidden = true;
+ $('tee').hidden = true;
+ $('btn-tee').textContent = 'TEE 驗證 · 是同一位 agent 嗎';
if (!quiet) note.textContent = '交易還沒進區塊,等幾秒再按一次回驗。';
return false;
}
@@ -892,6 +1029,10 @@ function initResult() {
sfx.tap();
uploadToStorage();
});
+ $('btn-tee').addEventListener('click', () => {
+ sfx.tap();
+ verifyTee();
+ });
$('btn-again').addEventListener('click', () => {
sfx.tap();
$('btn-anchor').textContent = '錨定到 0G Chain';
diff --git a/public/js/rules.js b/public/js/rules.js
index fb897a5..716076d 100644
--- a/public/js/rules.js
+++ b/public/js/rules.js
@@ -29,7 +29,11 @@ export const HERO_CARDS = {
name: '憶哨兵',
en: 'Memory Sentinel',
cost: 1,
- atk: 2,
+ // 3 攻是刻意跟遺忘者的雜訊看齊的。壓制傷害只看攻擊力總和,所以真正的貨幣是
+ // 「每點算力換到多少攻擊力」—— 哨兵原本 2 攻、雜訊 3 攻,等於玩家每條迴廊
+ // 都用比較差的匯率在買,模擬下來對上會下棋的對手是 0% 勝率。拉平之後
+ // 玩家的優勢改成體現在血量(3 vs 2)與零重刃的 4 攻爆發上。
+ atk: 3,
hp: 3,
kind: 'unit',
glyph: '哨',
@@ -228,13 +232,25 @@ function sweepDead(state, events) {
* 5) 壓制:交戰「之前」先比較每條迴廊雙方的總攻擊力,高的一方打對方核心。
* 「誰站得住」改成「誰的火力大」,是為了打破鏡像僵局 —— 只看有沒有人的話,
* 雙方每回合各補一個擋路兵就能鎖死全部三條迴廊,整局零傷害收在和局。
- * 比火力就一定會分出高下,玩家也有明確的施力點:這條要壓過去就得投更重的牌。
* 先結算是因為交戰常常同歸於盡,等打完再看迴廊早就空了,投入多少都白費。
*
+ * 傷害隨火力差遞增,不是贏者全拿。原本只要多 1 點攻擊力就直接拿滿 2 點,
+ * 整個平衡因此變成「誰的算力換攻擊力效率高,誰就贏走一切」的離散跳躍:
+ * 模擬顯示調 1 點攻擊力,結果會在「必輸 / 必和 / 必贏」之間直接跳過去,
+ * 中間沒有可以調的地帶。改成 min(2, ceil(差 / 2)) 之後上限仍是 2 點
+ * (沒有任何一條迴廊變得比以前更危險),但小優勢只換到小傷害,
+ * 要拿滿得壓過 3 攻以上 —— 「這條要壓過去就得投更重的牌」才真的有梯度回報。
+ *
* 推進刻意「同時」結算:先算出所有單位的意圖再一起套用,
* 否則先掃描到的一方會白撿一格,形成不公平的先手優勢。
*/
+/** 壓制傷害的上限。實際傷害是 min(CONTROL_DAMAGE, ceil(火力差 / CONTROL_STEP))。 */
export const CONTROL_DAMAGE = 2;
+export const CONTROL_STEP = 2;
+
+/** 火力差 → 壓制傷害。差 1~2 給 1 點,差 3 以上給滿 2 點,打平不給。 */
+export const controlDamage = (diff) =>
+ diff <= 0 ? 0 : Math.min(CONTROL_DAMAGE, Math.ceil(diff / CONTROL_STEP));
export function resolveCombat(state) {
const events = [];
@@ -252,8 +268,9 @@ export function resolveCombat(state) {
if (heroAtk === foeAtk) continue; // 火力打平就沒有人拿到控制權
const winner = heroAtk > foeAtk ? 'hero' : 'forgetter';
const loser = winner === 'hero' ? 'forgetter' : 'hero';
- state.core[loser] -= CONTROL_DAMAGE;
- events.push({ kind: 'control', side: winner, lane: l, amount: CONTROL_DAMAGE });
+ const amount = controlDamage(Math.abs(heroAtk - foeAtk));
+ state.core[loser] -= amount;
+ events.push({ kind: 'control', side: winner, lane: l, amount });
}
// 2) 貼身交戰
diff --git a/public/js/story.js b/public/js/story.js
index cfe25a5..888622e 100644
--- a/public/js/story.js
+++ b/public/js/story.js
@@ -39,9 +39,9 @@ export const BRIEFING = {
lines: [
'三條「記憶迴廊」,你在左、遺忘者在右。',
'每回合 2 點算力 —— 顧不了三條,選哪條放掉就是勝負。',
- '交戰前先比火力:一條迴廊誰的攻擊力總和高,就啃對方核心 2 點。',
+ '交戰前先比火力:一條迴廊贏多少就啃多少 —— 多 1~2 攻啃 1 點,多 3 攻以上啃滿 2 點。',
'核心 12 點,共 7 回合,先歸零的一方輸。',
- '對面是真的 AI agent —— 它每回合重新讀盤、重新決定。',
+ '對面是真的 AI agent —— 它跟你同時出牌,看不到你這回合放了什麼。',
],
};