acmer-coach 2.2.1 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/assets/knowledge/vp.html +50 -37
- package/assets/rules/coach-rules.md +2 -1
- package/dev/strategy-check.mjs +25 -0
- package/dev/vp-client-check.mjs +2 -0
- package/dev/workspace-migration-check.mjs +34 -0
- package/dev.sh +1 -1
- package/index.js +83 -15
- package/lib/strategy.js +2 -2
- package/lib/vp-client.js +6 -0
- package/package.json +1 -1
- package/verify-shape.mjs +1 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
## 它到底做了什么
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
30 个工具,三条轴:知识图、比赛能力、训练证据。
|
|
24
24
|
|
|
25
25
|
| 轴 | 含义 |
|
|
26
26
|
|---|---|
|
|
@@ -103,11 +103,15 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
|
|
|
103
103
|
现在可以直接连接 VP:`coach_vp_connect start` 打开授权页,登录并允许只读访问后,
|
|
104
104
|
把一次性 code 交给 `coach_vp_connect finish`。然后用 `coach_vp_sync` 增量同步比赛、
|
|
105
105
|
提交、事件和个人历史;同步会自动生成带账号前缀的 VP 训练证据,仍由复盘和检测决定
|
|
106
|
-
|
|
106
|
+
能力结论。小鲸先用 `coach_vp_contests` 按比赛名称选场,再调用 `coach_vp_replay`,不需要你记 Contest ID。
|
|
107
|
+
`VP 回放`按钮会打开可视化页面,
|
|
107
108
|
把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
|
|
108
109
|
|
|
109
110
|
每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
|
|
110
|
-
|
|
111
|
+
同步游标、原始安全投影、回放缓存和 `VP_EVENTS.yaml` 都按账号分开。个人训练数据
|
|
112
|
+
`PROGRESS.yaml`、`CURRICULUM.yaml`、`SCHEDULE.yaml`、`TARGET.yaml` 始终留在
|
|
113
|
+
`~/.dsh/knowledge/`,切换 VP 账号不会换教练。2.3.1 会把旧版本误写进账号目录的个人文件按更新时间迁回;被替换的主目录文件会留下 `.pre-vp-account-<user_id>` 备份,原账号文件不删除。
|
|
114
|
+
服务端
|
|
111
115
|
返回的账号 id 与当前账号不一致时,客户端拒绝写入。同步和回放都不返回提交代码或 judge 输出。
|
|
112
116
|
`disconnect` 只删除本地 token,保留缓存,方便重新连接同一个账号。
|
|
113
117
|
|
package/assets/knowledge/vp.html
CHANGED
|
@@ -3,45 +3,58 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
-
<title>VP
|
|
6
|
+
<title>VP Lab · 小鲸教练</title>
|
|
7
7
|
<style>
|
|
8
|
-
:root { color-scheme: dark; --
|
|
9
|
-
* { box-sizing:border-box } body { margin:0;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
#
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
:root { color-scheme: dark; --ink:#edf3f8; --muted:#8d9aa8; --surface:#111a24; --surface-2:#172432; --line:#263747; --cyan:#6de0db; --coral:#ff8b78; --gold:#f6c76b; }
|
|
9
|
+
* { box-sizing:border-box } body { margin:0; min-height:100vh; color:var(--ink); background:#091119; font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
10
|
+
body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(900px 460px at 72% -8%,#15404a55,transparent 65%),radial-gradient(620px 420px at 0 45%,#24314d38,transparent 72%); }
|
|
11
|
+
button,input { font:inherit } button { cursor:pointer; border:0 } .app { position:relative; display:grid; grid-template-columns:300px minmax(0,1fr); min-height:100vh; max-width:1500px; margin:auto; }
|
|
12
|
+
.rail { border-right:1px solid var(--line); background:#0d1721cc; backdrop-filter:blur(18px); padding:26px 18px 20px; display:flex; flex-direction:column; }
|
|
13
|
+
.brand { display:flex; align-items:center; gap:11px; padding:0 8px 25px; border-bottom:1px solid var(--line); } .mark { width:35px; height:35px; display:grid; place-items:center; border:1px solid #6de0db66; border-radius:12px; color:var(--cyan); background:#14383a; font-weight:800; letter-spacing:-2px } .eyebrow { color:var(--cyan); text-transform:uppercase; letter-spacing:.15em; font-size:10px; font-weight:700 } .brand h1 { margin:2px 0 0; font-size:18px; letter-spacing:-.02em }
|
|
14
|
+
.rail-head { display:flex; align-items:center; justify-content:space-between; padding:22px 8px 12px; } .rail-head h2 { margin:0; font-size:13px; color:#c8d4df } .icon-btn { width:30px; height:30px; border-radius:8px; color:var(--muted); background:transparent; border:1px solid transparent } .icon-btn:hover { color:var(--ink); border-color:var(--line); background:var(--surface-2) }
|
|
15
|
+
#contests { display:flex; flex-direction:column; gap:8px; overflow:auto; padding:0 2px; } .contest { text-align:left; width:100%; padding:12px; border-radius:11px; border:1px solid transparent; color:var(--ink); background:transparent } .contest:hover { background:#17243288; border-color:var(--line) } .contest.active { background:#183438; border-color:#3e8582; box-shadow:inset 3px 0 var(--cyan) } .contest-title { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:650; } .contest-meta { display:flex; justify-content:space-between; gap:6px; margin-top:5px; color:var(--muted); font-size:12px } .tag { color:var(--gold); }
|
|
16
|
+
.empty { color:var(--muted); padding:17px 10px; font-size:13px; } .rail-foot { margin-top:auto; padding:15px 8px 0; border-top:1px solid var(--line); color:var(--muted); font-size:12px; } .account-line { display:flex; align-items:center; gap:8px; color:#c9d7e3; margin-bottom:10px } .avatar { width:23px; height:23px; border-radius:50%; display:grid; place-items:center; color:#081116; background:var(--cyan); font-size:11px; font-weight:800 }
|
|
17
|
+
.main { min-width:0; padding:29px clamp(22px,4vw,58px) 45px; } .top { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:30px } .top h2 { margin:5px 0 6px; font-size:31px; line-height:1.1; letter-spacing:-.045em } .top p { margin:0; color:var(--muted); max-width:570px } .status { display:flex; align-items:center; gap:8px; padding:8px 12px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:#101b26aa; white-space:nowrap; font-size:12px } .status i { width:7px; height:7px; border-radius:50%; background:var(--coral); box-shadow:0 0 12px var(--coral) } .status.ok { color:#bcece1; border-color:#31716d; } .status.ok i { background:var(--cyan); box-shadow:0 0 12px var(--cyan) }
|
|
18
|
+
.hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:17px; margin-bottom:17px; } .hero-card,.panel { border:1px solid var(--line); border-radius:17px; background:linear-gradient(145deg,#172633ee,#0f1923ee); box-shadow:0 18px 60px #00000035; } .hero-card { padding:24px; min-height:190px; position:relative; overflow:hidden; } .hero-card:after { content:""; position:absolute; width:280px; height:280px; right:-110px; top:-150px; border-radius:50%; border:1px solid #6de0db24; box-shadow:0 0 0 22px #6de0db0d,0 0 0 44px #6de0db08; } .hero-card h3 { margin:9px 0 5px; max-width:75%; font-size:25px; letter-spacing:-.04em } .hero-card .sub { color:var(--muted); } .hero-date { color:var(--cyan); font-size:12px; letter-spacing:.02em } .hero-side { padding:20px; display:flex; flex-direction:column; justify-content:space-between; } .hero-side .label { color:var(--muted); font-size:12px } .hero-side strong { display:block; margin-top:5px; font-size:30px; letter-spacing:-.06em } .hero-side .small { color:var(--muted); font-size:12px }
|
|
19
|
+
.metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:20px; max-width:760px } .metric { padding:11px 13px; border:1px solid #2b3c4b; border-radius:10px; background:#0d1721aa } .metric span { display:block; color:var(--muted); font-size:11px } .metric b { display:block; margin-top:1px; font-size:20px; letter-spacing:-.04em }
|
|
20
|
+
.panel { padding:20px; margin-bottom:17px; } .panel-head { display:flex; align-items:end; justify-content:space-between; gap:12px; margin-bottom:16px } .panel-head h3 { margin:0; font-size:15px } .panel-head span { color:var(--muted); font-size:12px } #timeline { min-height:235px; border-radius:12px; background:#0b141d; border:1px solid #223342; padding:10px 12px; overflow:auto } svg { width:100%; min-width:700px; height:210px } .legend { display:flex; gap:15px; color:var(--muted); font-size:11px; padding:4px 7px 0 } .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px }
|
|
21
|
+
.below { display:grid; grid-template-columns:1.15fr .85fr; gap:17px } .problem { display:grid; grid-template-columns:32px minmax(0,1fr) auto; align-items:center; gap:10px; padding:11px 0; border-bottom:1px solid #22303d } .problem:last-child { border-bottom:0 } .problem-letter { display:grid; place-items:center; width:28px; height:28px; border-radius:8px; color:#071015; background:var(--gold); font-weight:800 } .problem-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .verdict { padding:3px 8px; border-radius:999px; color:#bcece1; background:#153d3b; font-size:11px }
|
|
22
|
+
.connect { display:flex; flex-direction:column; justify-content:center; min-height:190px; padding:26px; border:1px dashed #3c596d; border-radius:17px; background:#101e2b99 } .connect h3 { margin:0 0 6px; font-size:20px } .connect p { margin:0 0 17px; color:var(--muted); max-width:530px } .primary,.secondary { border-radius:9px; padding:10px 14px; font-weight:650 } .primary { color:#071015; background:var(--cyan); } .primary:hover { background:#9af1ea } .secondary { color:#cbd8e4; border:1px solid var(--line); background:#182635; } .secondary:hover { border-color:#55758c; background:#203448 } .actions { display:flex; flex-wrap:wrap; gap:8px }
|
|
23
|
+
.modal { position:fixed; inset:0; z-index:5; display:grid; place-items:center; padding:20px; background:#03080dcc; backdrop-filter:blur(9px) } .modal-card { width:min(470px,100%); padding:23px; border:1px solid #416172; border-radius:16px; background:#132331; box-shadow:0 24px 80px #0008 } .modal-card h3 { margin:0 0 7px; font-size:21px } .modal-card p { color:var(--muted); margin:0 0 17px } input { width:100%; padding:11px 12px; color:var(--ink); border:1px solid var(--line); border-radius:8px; outline:0; background:#0c151e } input:focus { border-color:var(--cyan); box-shadow:0 0 0 3px #6de0db1c } .modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:13px } #message,#modalMsg { min-height:21px; margin-top:13px; color:var(--muted); font-size:12px }
|
|
24
|
+
[hidden] { display:none !important } @media (max-width:900px) { .app{grid-template-columns:235px 1fr}.hero{grid-template-columns:1fr}.hero-side{min-height:130px}.below{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)} } @media (max-width:650px) { .app{display:block}.rail{border-right:0;border-bottom:1px solid var(--line);padding:16px}.rail-foot{display:none}.main{padding:23px 15px}.top{display:block}.status{display:inline-flex;margin-top:15px}.top h2{font-size:26px}.hero-card,.panel{border-radius:13px} }
|
|
19
25
|
</style>
|
|
20
26
|
</head>
|
|
21
|
-
<body
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
<body>
|
|
28
|
+
<div class="app">
|
|
29
|
+
<aside class="rail">
|
|
30
|
+
<div class="brand"><div class="mark">鲸</div><div><div class="eyebrow">ACMER COACH</div><h1>VP Lab</h1></div></div>
|
|
31
|
+
<div class="rail-head"><h2>我的 VP 比赛</h2><button id="refresh" class="icon-btn" title="刷新比赛列表" aria-label="刷新比赛列表">↻</button></div>
|
|
32
|
+
<div id="contests"><div class="empty">正在读取比赛…</div></div>
|
|
33
|
+
<div class="rail-foot"><div class="account-line"><span id="avatar" class="avatar">?</span><span id="accountName">未连接 VP</span></div><span>选择比赛后,小鲸可以直接读取同一场回放。</span></div>
|
|
34
|
+
</aside>
|
|
35
|
+
<main class="main">
|
|
36
|
+
<header class="top"><div><div class="eyebrow">REPLAY WORKSPACE</div><h2>把比赛过程摊开看</h2><p>回放不是给你查编号的。选一场 VP,小鲸会拿到提交节奏、失败位置和队伍时间线,用它来做复盘和下一周训练安排。</p></div><div id="status" class="status"><i></i><span>检查账号</span></div></header>
|
|
37
|
+
<section id="connectCard" class="connect" hidden><h3>先连接你的 VP</h3><p>只读授权。密码留在 VP,教练拿到的是独立 token;账号切换后,历史缓存不会混在一起。</p><div class="actions"><button id="start" class="primary">打开 VP 登录授权</button><button id="syncEmpty" class="secondary">我已经有 code</button></div></section>
|
|
38
|
+
<section id="workspace" hidden>
|
|
39
|
+
<div class="hero"><div class="hero-card"><div class="hero-date" id="heroDate">等待选择比赛</div><h3 id="heroTitle">从左侧选一场 VP</h3><div class="sub" id="heroSub">这里会显示比赛状态、队伍和训练上下文。</div><div id="metrics" class="metrics"></div></div><div class="hero-side panel"><div><div class="label">当前视角</div><strong id="heroRank">—</strong><div class="small">你的队伍 · 比赛结果</div></div><div class="small" id="heroStatus">未载入回放</div></div></div>
|
|
40
|
+
<div class="panel"><div class="panel-head"><h3>比赛节奏</h3><span id="timelineHint">选择比赛后自动加载</span></div><div id="timeline"></div></div>
|
|
41
|
+
<div class="below"><div class="panel"><div class="panel-head"><h3>题目轨迹</h3><span id="problemHint">—</span></div><div id="problems"><div class="empty">选择比赛后显示</div></div></div><div class="panel"><div class="panel-head"><h3>给小鲸的入口</h3><span>分析建议</span></div><p class="muted" style="margin:0 0 14px">小鲸可以直接调用 <code>coach_vp_replay</code> 获取当前比赛的秒级事件,再结合 <code>coach_postmortem</code> 把事实变成训练动作。</p><div class="actions"><button id="sync" class="primary">同步最新 VP</button><button id="disconnect" class="secondary">切换账号</button></div><div id="message"></div></div></div>
|
|
29
42
|
</section>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</div>
|
|
34
|
-
</main>
|
|
43
|
+
</main>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="modal" class="modal" hidden><div class="modal-card"><h3>完成 VP 授权</h3><p>授权页允许读取后,把页面显示的一次性 code 粘贴到这里。</p><input id="code" autocomplete="off" placeholder="粘贴 code"><div class="modal-actions"><button id="cancel" class="secondary">取消</button><button id="finish" class="primary">完成连接</button></div><div id="modalMsg"></div></div></div>
|
|
35
46
|
<script>
|
|
36
|
-
const $ =
|
|
37
|
-
const say = (
|
|
38
|
-
async function api(path, options) { const r=await fetch(path,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
$('
|
|
45
|
-
function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1),
|
|
46
|
-
|
|
47
|
-
</script
|
|
47
|
+
const $ = id => document.getElementById(id); let selected = null; let contests = [];
|
|
48
|
+
const say = (s,bad=false) => { $('message').textContent=s; $('message').style.color=bad?'var(--coral)':'' }
|
|
49
|
+
async function api(path, options={}) { const r=await fetch(path,{headers:{'content-type':'application/json'},...options}); const d=await r.json(); if(!r.ok||d.ok===false) throw Error(d.reject||`HTTP ${r.status}`); return d }
|
|
50
|
+
function esc(v){return String(v??'').replace(/[&<>"']/g,x=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[x]))}
|
|
51
|
+
function dateOf(v) { if(!v)return '日期未知'; const raw=String(v).replace(' ','T'); const d=new Date(/[zZ]|[+-]\d\d:\d\d$/.test(raw)?raw:`${raw}+08:00`); return Number.isNaN(d.getTime())?String(v).slice(0,10):d.toLocaleDateString('zh-CN',{month:'short',day:'numeric'}) }
|
|
52
|
+
function setStatus(ok,name='未连接 VP') { $('status').className='status'+(ok?' ok':''); $('status').innerHTML=`<i></i><span>${ok?`已连接 · ${esc(name)}`:'未连接 VP'}</span>`; $('accountName').textContent=ok?name:'未连接 VP'; $('avatar').textContent=ok?name.slice(0,1).toUpperCase():'?'; }
|
|
53
|
+
async function loadStatus() { try { const d=await api('./api/vp/status'); if(!d.connected){setStatus(false);$('connectCard').hidden=false;$('workspace').hidden=true;return false} setStatus(true,d.account.display_name||d.account.handle);$('connectCard').hidden=true;$('workspace').hidden=false;return true } catch(e){say(e.message,true);return false} }
|
|
54
|
+
async function loadContests() { try { const d=await api('./api/vp/contests'); contests=d.contests||[]; $('contests').innerHTML=contests.length?contests.map(c=>`<button class="contest ${selected?.id===c.id?'active':''}" data-id="${c.id}"><span class="contest-title">${esc(c.title)}</span><span class="contest-meta"><span>${dateOf(c.start_time)}</span><span class="tag">${c.status==='ended'?'已结束':'进行中'}</span></span></button>`).join(''):'<div class="empty">还没有可看的 VP 比赛。先点“同步最新 VP”。</div>'; document.querySelectorAll('.contest').forEach(b=>b.onclick=()=>selectContest(Number(b.dataset.id))); } catch(e){$('contests').innerHTML=`<div class="empty">${esc(e.message)}</div>`} }
|
|
55
|
+
async function selectContest(id) { selected=contests.find(c=>c.id===id)||{id}; document.querySelectorAll('.contest').forEach(b=>b.classList.toggle('active',Number(b.dataset.id)===id)); $('heroTitle').textContent=selected.title||`Contest ${id}`; $('heroDate').textContent=`${dateOf(selected.start_time)} · ${selected.duration_min||300} 分钟`; $('heroSub').textContent=`${selected.status==='ended'?'已结束':'进行中'} · 选择后回放会自动加载`; try { const d=await api(`./api/vp/replay?contestId=${encodeURIComponent(id)}`); render(d) } catch(e){say(e.message,true)} }
|
|
56
|
+
function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1), solved=d.result?.own?.solved??d.result?.solved??0; $('heroStatus').textContent=`${ev.length} 个队伍事件 · ${v.official_run_count||0} 条官方记录`; $('heroRank').textContent=d.result?.rank?`#${d.result.rank}`:'—'; $('metrics').innerHTML=`<div class="metric"><span>解决题数</span><b>${solved}</b></div><div class="metric"><span>队伍提交</span><b>${ev.length}</b></div><div class="metric"><span>官方 runs</span><b>${v.official_run_count||0}</b></div><div class="metric"><span>比赛时长</span><b>${Math.round(duration/60)}m</b></div>`; $('timelineHint').textContent=`${ev.length?'点击圆点看提交详情':'这场比赛暂无队伍提交'} · 横轴为比赛分钟`; const w=920,pad=48; const ticks=Array.from({length:7},(_,i)=>{const x=pad+(w-pad*2)*i/6;return `<line x1="${x}" y1="27" x2="${x}" y2="167" stroke="#22303d"/><text x="${x}" y="188" fill="#8d9aa8" text-anchor="middle">${Math.round(duration*i/6/60)}m</text>`}).join(''); const marks=ev.map((e,i)=>{const x=pad+(w-pad*2)*Math.min(1,Math.max(0,(e.second||0)/duration));const good=String(e.verdict||'').toUpperCase()==='AC';const color=good?'#6de0db':'#ff8b78';return `<g class="event" tabindex="0" data-event="${i}"><circle cx="${x}" cy="${good?70:119}" r="8" fill="${color}"/><circle cx="${x}" cy="${good?70:119}" r="14" fill="none" stroke="${color}" stroke-opacity=".18"/><title>${esc(e.problem||'')} · ${esc(e.verdict||'')} · ${Math.round((e.second||0)/60)}m</title></g>`}).join(''); $('timeline').innerHTML=`<svg viewBox="0 0 ${w} 205" role="img" aria-label="比赛提交时间轴"><text x="8" y="74" fill="#6de0db">AC</text><text x="8" y="123" fill="#ff8b78">失败</text>${ticks}<line x1="${pad}" y1="70" x2="${w-pad}" y2="70" stroke="#6de0db" stroke-opacity=".65"/><line x1="${pad}" y1="119" x2="${w-pad}" y2="119" stroke="#ff8b78" stroke-opacity=".65"/>${marks}</svg><div class="legend"><span><i class="dot" style="background:#6de0db"></i>AC</span><span><i class="dot" style="background:#ff8b78"></i>WA / TLE / RE</span><span>比赛墙上时间 · 不是评测耗时</span></div>`; document.querySelectorAll('.event').forEach(g=>g.onclick=()=>{const e=ev[Number(g.dataset.event)];say(`${e.problem||''} · ${e.verdict||''} · ${Math.round((e.second||0)/60)} 分钟 · ${e.display_name||''}`)}); $('problemHint').textContent=`${(d.problems||[]).length} 道题`; $('problems').innerHTML=(d.problems||[]).map(p=>`<div class="problem"><span class="problem-letter">${esc(p.label||p.id||'?')}</span><span class="problem-title">${esc(p.title||'未命名题目')}</span><span class="verdict">${esc(p.id||'—')}</span></div>`).join('')||'<div class="empty">没有题目数据</div>' }
|
|
57
|
+
$('start').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'start'})}); window.open(d.authorizeUrl,'_blank','noopener'); $('modal').hidden=false; $('modalMsg').textContent='授权页已打开,登录并允许读取后粘贴 code。'}catch(e){say(e.message,true)}}; $('syncEmpty').onclick=()=>{$('modal').hidden=false}; $('cancel').onclick=()=>{$('modal').hidden=true}; $('finish').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'finish',code:$('code').value})});$('modal').hidden=true;$('code').value='';await loadStatus();await loadContests();say('连接成功,比赛列表已更新。')}catch(e){$('modalMsg').textContent=e.message;$('modalMsg').style.color='var(--coral)'}}; $('refresh').onclick=loadContests; $('sync').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'sync'})});say(`同步完成:${d.submissions?.length||0} 条提交,${d.contests?.length||0} 场比赛。`);await loadContests();if(selected)await selectContest(selected.id)}catch(e){say(e.message,true)}}; $('disconnect').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'disconnect'})});selected=null;await loadStatus()}catch(e){say(e.message,true)}}; (async()=>{if(await loadStatus())await loadContests()})()
|
|
58
|
+
</script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
为什么现在排、暂缓什么,以及用什么表现退出。知识图只负责把能力重点展开成可学节点。
|
|
17
17
|
* 有 VP 时先用 `coach_vp_connect status` 检查账号,未连接就走 `start → 登录授权页 → finish`,
|
|
18
18
|
再用 `coach_vp_sync` 拉取真实记录;赛后调 `coach_vp_replay` 看秒级时间轴,最后调
|
|
19
|
-
`coach_postmortem
|
|
19
|
+
`coach_postmortem`。需要回放时先用 `coach_vp_contests` 按比赛名称选择,再调
|
|
20
|
+
`coach_vp_replay`;不要让学生提供 Contest ID。VP 的读题、耗时、提交、换题和团队问题优先于 rating;没有 VP 证据时要
|
|
20
21
|
明确说“尚无记录”,不能装作知道短板。手工导入仍可用 `coach_vp_import`,但不能和当前账号记录混用。
|
|
21
22
|
* 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
|
|
22
23
|
条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
|
package/dev/strategy-check.mjs
CHANGED
|
@@ -147,6 +147,31 @@ test('invalid target priorities are rejected instead of silently dropped', () =>
|
|
|
147
147
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
148
148
|
})
|
|
149
149
|
|
|
150
|
+
test('personal target stays shared while VP events stay account-scoped', () => {
|
|
151
|
+
const personal = mkdtempSync(join(tmpdir(), 'acmer-coach-personal-'))
|
|
152
|
+
const accountA = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-a-'))
|
|
153
|
+
const accountB = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-b-'))
|
|
154
|
+
const options = {
|
|
155
|
+
competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const a = createStrategyStore({ ...options, dataDir: personal, vpDir: accountA })
|
|
159
|
+
const b = createStrategyStore({ ...options, dataDir: personal, vpDir: accountB })
|
|
160
|
+
const target = { expectedRevision: 0, contest: '西安区域赛', date: '2026-10-17', result: '保铜争银', weeklyHours: 25,
|
|
161
|
+
priorities: [{ id: 'implementation-stability', weight: 100 }] }
|
|
162
|
+
assert.equal(a.target('set', target).ok, true)
|
|
163
|
+
assert.equal(a.vpImport([{ eventId: 'account-a', contest: 'VP A', date: '2026-10-10', durationMinutes: 300,
|
|
164
|
+
problems: [{ problemId: 'A', status: 'AC' }] }]).ok, true)
|
|
165
|
+
assert.equal(b.target('read').contest, '西安区域赛')
|
|
166
|
+
assert.equal(b.vpImport([]).total, 0)
|
|
167
|
+
assert.equal(existsSync(join(personal, 'TARGET.yaml')), true)
|
|
168
|
+
assert.equal(existsSync(join(accountA, 'VP_EVENTS.yaml')), true)
|
|
169
|
+
assert.equal(existsSync(join(accountB, 'VP_EVENTS.yaml')), false)
|
|
170
|
+
} finally {
|
|
171
|
+
for (const dir of [personal, accountA, accountB]) rmSync(dir, { recursive: true, force: true })
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
150
175
|
test('corrupt stored strategy data returns a structured refusal', () => {
|
|
151
176
|
const { dir, store } = fixture()
|
|
152
177
|
try {
|
package/dev/vp-client-check.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const fetchImpl = async (url, options = {}) => {
|
|
|
16
16
|
return json({ access_token: `token-${account.id}`, user: account })
|
|
17
17
|
}
|
|
18
18
|
if (path.endsWith('/api/coach/me')) return json({ user: account, read_only: true })
|
|
19
|
+
if (path.endsWith('/api/coach/contests')) return json({ account, contests: [{ id: 7, title: 'VP 7', status: 'ended' }] })
|
|
19
20
|
if (path.endsWith('/api/coach/sync')) return json({
|
|
20
21
|
account: mismatch ? { id: 1, handle: 'A' } : account,
|
|
21
22
|
submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
|
|
@@ -36,6 +37,7 @@ try {
|
|
|
36
37
|
assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
|
|
37
38
|
await client.finish({ code: 'B' })
|
|
38
39
|
assert.equal((await client.status()).account.id, 2)
|
|
40
|
+
assert.equal((await client.contests()).contests[0].id, 7)
|
|
39
41
|
assert.equal((await client.sync()).account.id, 2)
|
|
40
42
|
mismatch = true
|
|
41
43
|
await assert.rejects(() => client.sync(), /account mismatch/)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
test('startup migrates legacy personal files out of an active VP account', async () => {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-workspace-'))
|
|
9
|
+
const accountDir = join(root, 'vp-accounts', '6')
|
|
10
|
+
mkdirSync(accountDir, { recursive: true })
|
|
11
|
+
writeFileSync(join(root, 'VP_ACCOUNTS.json'), JSON.stringify({
|
|
12
|
+
version: 1,
|
|
13
|
+
activeAccountId: 6,
|
|
14
|
+
accounts: { 6: { account: { id: 6, handle: 'Mianiii', display_name: 'Mianiii' }, baseUrl: 'https://example.test' } },
|
|
15
|
+
}))
|
|
16
|
+
writeFileSync(join(root, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-10T20:55:57.385Z\ncontest: 旧目标\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
|
|
17
|
+
writeFileSync(join(accountDir, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-11T00:51:42.901Z\ncontest: 西安区域赛\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
|
|
18
|
+
const oldTargetTime = new Date('2026-10-10T20:55:57.385Z')
|
|
19
|
+
const newTargetTime = new Date('2026-10-11T00:51:42.901Z')
|
|
20
|
+
const { utimesSync } = await import('node:fs')
|
|
21
|
+
utimesSync(join(root, 'TARGET.yaml'), oldTargetTime, oldTargetTime)
|
|
22
|
+
utimesSync(join(accountDir, 'TARGET.yaml'), newTargetTime, newTargetTime)
|
|
23
|
+
process.env.COACH_DATA_DIR = root
|
|
24
|
+
try {
|
|
25
|
+
await import(`../index.js?workspace-migration=${Date.now()}`)
|
|
26
|
+
assert.equal(existsSync(join(root, 'TARGET.yaml')), true)
|
|
27
|
+
assert.equal(existsSync(join(accountDir, 'TARGET.yaml')), true)
|
|
28
|
+
assert.equal(existsSync(join(root, 'TARGET.yaml.pre-vp-account-6')), true)
|
|
29
|
+
assert.match((await import('node:fs')).readFileSync(join(root, 'TARGET.yaml'), 'utf8'), /contest: 西安区域赛/)
|
|
30
|
+
} finally {
|
|
31
|
+
delete process.env.COACH_DATA_DIR
|
|
32
|
+
rmSync(root, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
})
|
package/dev.sh
CHANGED
|
@@ -35,7 +35,7 @@ done
|
|
|
35
35
|
# 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
|
|
36
36
|
# 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
|
|
37
37
|
# 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
|
|
38
|
-
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
|
|
38
|
+
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
|
|
39
39
|
STALE=''
|
|
40
40
|
for f in $SYNC_FILES; do
|
|
41
41
|
for d in "${DSTS[@]}"; do
|
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
|
|
24
24
|
// 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
|
|
25
25
|
// 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
|
|
26
|
-
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
|
|
26
|
+
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync, existsSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
import { homedir } from 'node:os'
|
|
29
29
|
import { parse, stringify } from 'yaml'
|
|
@@ -36,7 +36,7 @@ import { createVpClient } from './lib/vp-client.js'
|
|
|
36
36
|
const name = 'acmer-coach'
|
|
37
37
|
const inject = ['tools']
|
|
38
38
|
|
|
39
|
-
const VERSION = '2.
|
|
39
|
+
const VERSION = '2.3.1'
|
|
40
40
|
|
|
41
41
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
42
42
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -72,30 +72,66 @@ const TEACH_FLOOR_MIN = 10
|
|
|
72
72
|
const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
|
|
73
73
|
const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
|
|
74
74
|
const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
|
|
75
|
-
let
|
|
75
|
+
let ACTIVE_VP_DIR = DATA_DIR
|
|
76
76
|
let strategy
|
|
77
77
|
let curriculum
|
|
78
|
-
function createStores(
|
|
78
|
+
function createStores(vpDir) {
|
|
79
79
|
strategy = createStrategyStore({
|
|
80
|
-
dataDir
|
|
80
|
+
dataDir: DATA_DIR, vpDir,
|
|
81
|
+
competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
81
82
|
})
|
|
82
83
|
curriculum = createCurriculumStore({
|
|
83
|
-
dataDir, loadMap, loadProgress, loadSchedule,
|
|
84
|
+
dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
|
|
84
85
|
loadStrategyEvidence: () => strategy.evidence().evidence,
|
|
85
86
|
})
|
|
86
87
|
}
|
|
87
|
-
createStores(
|
|
88
|
+
createStores(ACTIVE_VP_DIR)
|
|
89
|
+
|
|
90
|
+
const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
|
|
91
|
+
|
|
92
|
+
// 2.3.0 曾把个人文件误写进 VP 账号目录。迁移时按 mtime 取较新的一份;
|
|
93
|
+
// 被替换的主目录文件留备份,原账号目录也保留,方便人工核对和恢复。
|
|
94
|
+
function migratePersonalWorkspace(accountId) {
|
|
95
|
+
if (!accountId) return []
|
|
96
|
+
const sourceDir = join(DATA_DIR, 'vp-accounts', String(accountId))
|
|
97
|
+
if (!existsSync(sourceDir)) return []
|
|
98
|
+
const migrated = []
|
|
99
|
+
for (const file of PERSONAL_FILES) {
|
|
100
|
+
const from = join(sourceDir, file)
|
|
101
|
+
const to = join(DATA_DIR, file)
|
|
102
|
+
if (!existsSync(from)) continue
|
|
103
|
+
if (!existsSync(to)) {
|
|
104
|
+
copyFileSync(from, to)
|
|
105
|
+
migrated.push(file)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const sourceStat = statSync(from)
|
|
109
|
+
const targetStat = statSync(to)
|
|
110
|
+
if (sourceStat.mtimeMs <= targetStat.mtimeMs) continue
|
|
111
|
+
const sourceBody = readFileSync(from)
|
|
112
|
+
const targetBody = readFileSync(to)
|
|
113
|
+
if (sourceBody.equals(targetBody)) continue
|
|
114
|
+
const backup = `${to}.pre-vp-account-${String(accountId)}`
|
|
115
|
+
if (!existsSync(backup)) copyFileSync(to, backup)
|
|
116
|
+
copyFileSync(from, to)
|
|
117
|
+
migrated.push(`${file}(更新)`)
|
|
118
|
+
}
|
|
119
|
+
return migrated
|
|
120
|
+
}
|
|
88
121
|
|
|
89
122
|
function switchCoachWorkspace(accountId = '') {
|
|
90
|
-
|
|
91
|
-
mkdirSync(
|
|
92
|
-
createStores(
|
|
93
|
-
return
|
|
123
|
+
ACTIVE_VP_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
|
|
124
|
+
mkdirSync(ACTIVE_VP_DIR, { recursive: true })
|
|
125
|
+
createStores(ACTIVE_VP_DIR)
|
|
126
|
+
return ACTIVE_VP_DIR
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
const vpClient = createVpClient({ dataDir: DATA_DIR })
|
|
97
130
|
const savedVp = vpClient.localStatus()
|
|
98
|
-
if (savedVp.connected)
|
|
131
|
+
if (savedVp.connected) {
|
|
132
|
+
migratePersonalWorkspace(savedVp.activeAccountId)
|
|
133
|
+
switchCoachWorkspace(savedVp.activeAccountId)
|
|
134
|
+
}
|
|
99
135
|
|
|
100
136
|
// 探针要的文件,各自代表一条后面的依赖:
|
|
101
137
|
// MAP.yaml 地图(coach_next 读它算候选)
|
|
@@ -187,7 +223,7 @@ function loadMap() {
|
|
|
187
223
|
//
|
|
188
224
|
// 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
|
|
189
225
|
// 而且他想复习某节时游标要能停在那儿。
|
|
190
|
-
const PROGRESS_PATH = () => join(
|
|
226
|
+
const PROGRESS_PATH = () => join(DATA_DIR, 'PROGRESS.yaml')
|
|
191
227
|
|
|
192
228
|
function emptyProgress() {
|
|
193
229
|
return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
|
|
@@ -326,7 +362,7 @@ function saveProgress(p) {
|
|
|
326
362
|
//
|
|
327
363
|
// `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
|
|
328
364
|
// 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
|
|
329
|
-
const SCHEDULE_PATH = () => join(
|
|
365
|
+
const SCHEDULE_PATH = () => join(DATA_DIR, 'SCHEDULE.yaml')
|
|
330
366
|
|
|
331
367
|
function emptySchedule() {
|
|
332
368
|
return { version: 1, updated: '', days: {} }
|
|
@@ -1519,6 +1555,11 @@ function registerPages(ctx) {
|
|
|
1519
1555
|
return sendJson(res, 200, { ok: true, ...vpClient.localStatus() })
|
|
1520
1556
|
}
|
|
1521
1557
|
|
|
1558
|
+
if (pathname === '/coach/api/vp/contests' && req.method === 'GET') {
|
|
1559
|
+
try { return sendJson(res, 200, { ok: true, ...(await vpClient.contests()) }) }
|
|
1560
|
+
catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err), contests: [] }) }
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1522
1563
|
if (pathname === '/coach/api/vp/replay' && req.method === 'GET') {
|
|
1523
1564
|
const id = new URL(req.url ?? '/', 'http://dsh.internal').searchParams.get('contestId')
|
|
1524
1565
|
if (!id) return sendJson(res, 400, { ok: false, reject: '缺少 contestId' })
|
|
@@ -1534,7 +1575,11 @@ function registerPages(ctx) {
|
|
|
1534
1575
|
try {
|
|
1535
1576
|
let result
|
|
1536
1577
|
if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
|
|
1537
|
-
else if (payload.op === 'finish') {
|
|
1578
|
+
else if (payload.op === 'finish') {
|
|
1579
|
+
result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl })
|
|
1580
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1581
|
+
switchCoachWorkspace(result.activeAccountId)
|
|
1582
|
+
}
|
|
1538
1583
|
else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
|
|
1539
1584
|
else if (payload.op === 'sync') result = await vpClient.sync({})
|
|
1540
1585
|
else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
|
|
@@ -1948,6 +1993,7 @@ function apply(ctx) {
|
|
|
1948
1993
|
if (action === 'start') result = await vpClient.start({ baseUrl: base })
|
|
1949
1994
|
else if (action === 'finish') {
|
|
1950
1995
|
result = await vpClient.finish({ code: args?.code, baseUrl: base })
|
|
1996
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1951
1997
|
switchCoachWorkspace(result.activeAccountId)
|
|
1952
1998
|
} else if (action === 'disconnect') {
|
|
1953
1999
|
result = vpClient.disconnect()
|
|
@@ -1992,6 +2038,28 @@ function apply(ctx) {
|
|
|
1992
2038
|
},
|
|
1993
2039
|
}))
|
|
1994
2040
|
|
|
2041
|
+
ctx.tools.register(defineTool({
|
|
2042
|
+
name: 'coach_vp_contests',
|
|
2043
|
+
description: '列出当前 VP 账号参加过的比赛,按最近开始时间排序。小鲸在调用 coach_vp_replay 前先用它拿到可读的比赛名称,不要求用户记 Contest ID。',
|
|
2044
|
+
parameters: {},
|
|
2045
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2046
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
|
|
2047
|
+
contests: { type: 'array', required: true }, text: { type: 'string', required: true },
|
|
2048
|
+
} }, render: (_a, v) => strategyText(v) },
|
|
2049
|
+
execute: async () => {
|
|
2050
|
+
try {
|
|
2051
|
+
const payload = await vpClient.contests()
|
|
2052
|
+
const account = vpAccount(payload.account)
|
|
2053
|
+
const contests = payload.contests || []
|
|
2054
|
+
return { ok: true, reject: '', account, contests, text: contests.length
|
|
2055
|
+
? `当前账号有 ${contests.length} 场 VP:\n${contests.slice(0, 20).map((item, i) => `${i + 1}. ${item.title}(${item.start_time || '日期未知'},id=${item.id})`).join('\n')}`
|
|
2056
|
+
: '当前账号没有可读取的 VP 比赛。' }
|
|
2057
|
+
} catch (err) {
|
|
2058
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), contests: [], text: '' }
|
|
2059
|
+
}
|
|
2060
|
+
},
|
|
2061
|
+
}))
|
|
2062
|
+
|
|
1995
2063
|
ctx.tools.register(defineTool({
|
|
1996
2064
|
name: 'coach_vp_replay',
|
|
1997
2065
|
description: '读取当前 VP 账号指定比赛的细粒度回放。返回题目、队伍提交和秒级时间轴;代码不回传,页面可视化从这个数据绘制。',
|
package/lib/strategy.js
CHANGED
|
@@ -105,9 +105,9 @@ function normalizePostmortem(value) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
|
|
108
|
+
export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath, now = () => new Date() }) {
|
|
109
109
|
const targetPath = join(dataDir, 'TARGET.yaml')
|
|
110
|
-
const vpPath = join(
|
|
110
|
+
const vpPath = join(vpDir, 'VP_EVENTS.yaml')
|
|
111
111
|
const validateTarget = (doc) => {
|
|
112
112
|
const errors = []
|
|
113
113
|
if (doc.version !== 1) errors.push('version 必须是 1')
|
package/lib/vp-client.js
CHANGED
|
@@ -109,6 +109,12 @@ export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = ()
|
|
|
109
109
|
return { connected: false, account: current.account, activeAccountId: current.id, error: err.message }
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
|
+
async contests() {
|
|
113
|
+
const current = requireActive()
|
|
114
|
+
const payload = await request(current.baseUrl, '/api/coach/contests', { token: current.token })
|
|
115
|
+
const account = verifyAccount(current, payload.account)
|
|
116
|
+
return { ...payload, account, contests: Array.isArray(payload.contests) ? payload.contests : [] }
|
|
117
|
+
},
|
|
112
118
|
async sync({ limit = 200, includeProfile = true } = {}) {
|
|
113
119
|
const current = requireActive()
|
|
114
120
|
const registry = loadRegistry()
|
package/package.json
CHANGED
package/verify-shape.mjs
CHANGED
|
@@ -177,6 +177,7 @@ const cases = [
|
|
|
177
177
|
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
178
|
['coach_vp_connect', { action: 'status' }],
|
|
179
179
|
['coach_vp_sync', {}],
|
|
180
|
+
['coach_vp_contests', {}],
|
|
180
181
|
['coach_vp_replay', { contestId: 1 }],
|
|
181
182
|
['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
|
|
182
183
|
['coach_focus', {}],
|