Commit 824ba494fd25129b375d03e913fbd016a71c1a54
1 parent
f59c66d0
Add T36: playback quota consumption + auto-expiry for paid characters
consumeCharacterSegment(clip), called alongside markPlayed() at both dance-specific scheduling call sites (T34's priorityInbox insert and the normal fill loop) — deliberately not folded into the shared markPlayed() itself, since live.html's text-channel clips never have a user_paid character behind them and architecture.md 2.1 keeps the two channels' scheduling logic independent. Decrements characters.remainingSegments each time a user_paid character's clip gets scheduled (its first play via T34's priority insert, and every later replay through the normal random-fill pool); hits 0 -> status:"expired". pickNextDanceClip's random-fill pool now excludes clips belonging to an expired character (需求 3.3 第 7 点: excluded from future selection, but the character record and its generated videos are never deleted — GET /api/admin/characters and GET /api/library still return them). dance-mode3.test.js: buying exactly 1 segment expires the character right after its single (priority-boosted) play; totalSegments stays 1 as history while remainingSegments goes to 0; the character and its clip remain queryable via the admin/library APIs afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
2 changed files
with
67 additions
and
1 deletions
dance-mode3.test.js
| @@ -256,6 +256,44 @@ test("full happy path: cost scales with requested segments, character carries us | @@ -256,6 +256,44 @@ test("full happy path: cost scales with requested segments, character carries us | ||
| 256 | assert.equal(clipView.garmentId, freeGarment.id); | 256 | assert.equal(clipView.garmentId, freeGarment.id); |
| 257 | }); | 257 | }); |
| 258 | 258 | ||
| 259 | +test("T36: a character bought with 1 segment expires after that single play, but its record and video stay queryable", async () => { | ||
| 260 | + const oneOffGarment = await fetch( | ||
| 261 | + `${BASE}/api/admin/garments`, | ||
| 262 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "一次性紫裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "purple dress" }) }) | ||
| 263 | + ).then((r) => r.json()); | ||
| 264 | + | ||
| 265 | + const cookie = await registerVerifiedUser(BASE); | ||
| 266 | + await topUp(BASE, cookie); | ||
| 267 | + const uploadRes = await uploadCharacter(BASE, cookie, { garmentId: oneOffGarment.id, segments: 1 }); | ||
| 268 | + assert.equal(uploadRes.status, 200); | ||
| 269 | + const { characterId } = await uploadRes.json(); | ||
| 270 | + | ||
| 271 | + // Scheduling (T34's eager priorityInbox insert) is what consumes the | ||
| 272 | + // segment — same tick that makes it visible in current/upNext, so no | ||
| 273 | + // extra wait beyond that is needed. | ||
| 274 | + await waitUntil(async () => { | ||
| 275 | + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); | ||
| 276 | + const all = [s.current, ...(s.upNext || [])].filter(Boolean); | ||
| 277 | + return all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === characterId) ? true : null; | ||
| 278 | + }); | ||
| 279 | + | ||
| 280 | + const expired = await waitUntil(async () => { | ||
| 281 | + const list = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); | ||
| 282 | + const found = list.find((c) => c.id === characterId); | ||
| 283 | + return found && found.status === "expired" ? found : null; | ||
| 284 | + }); | ||
| 285 | + assert.equal(expired.remainingSegments, 0); | ||
| 286 | + assert.equal(expired.totalSegments, 1, "totalSegments is history, unaffected by playing out"); | ||
| 287 | + | ||
| 288 | + // Record/video still retrievable, per 需求文档 3.3 第 7 点 — not deleted, | ||
| 289 | + // just no longer eligible for future random-fill picks (can't easily | ||
| 290 | + // assert the negative here without a long real-time wait, but the pool | ||
| 291 | + // filter in pickNextDanceClip is what dance-mode3's other tests already | ||
| 292 | + // exercise the positive side of). | ||
| 293 | + const clips = await fetch(`${BASE}/api/library`).then((r) => r.json()); | ||
| 294 | + assert.ok(clips.some((c) => c.characterId === characterId), "the generated video must still be retrievable after the character expires"); | ||
| 295 | +}); | ||
| 296 | + | ||
| 259 | test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => { | 297 | test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => { |
| 260 | // A second, distinct garment so this free pick doesn't hit M.7's | 298 | // A second, distinct garment so this free pick doesn't hit M.7's |
| 261 | // dedupe cache against a clip from an earlier test using the same | 299 | // dedupe cache against a clip from an earlier test using the same |
server.js
| @@ -772,6 +772,23 @@ function markPlayed(id) { | @@ -772,6 +772,23 @@ function markPlayed(id) { | ||
| 772 | saveLibrary(lib); | 772 | saveLibrary(lib); |
| 773 | } | 773 | } |
| 774 | 774 | ||
| 775 | +// T36 (dev-plan-5.md): dance.html-only, deliberately not folded into the | ||
| 776 | +// shared markPlayed() above — live.html's text-channel clips never have a | ||
| 777 | +// user_paid character behind them, and keeping this explicit (called only | ||
| 778 | +// from the two dance-specific scheduling call sites) matches | ||
| 779 | +// architecture.md 2.1's "two independent product lines" rather than | ||
| 780 | +// silently special-casing the shared helper. A clip's first play already | ||
| 781 | +// happened via T34's priorityInbox insert ("确定性上镜"); every play after | ||
| 782 | +// that — including this same clip recurring through the normal random-fill | ||
| 783 | +// pool — keeps consuming the same purchased quota until it runs out. | ||
| 784 | +function consumeCharacterSegment(clip) { | ||
| 785 | + if (!clip.characterId) return; | ||
| 786 | + const character = characters.get(clip.characterId); | ||
| 787 | + if (!character || character.source !== "user_paid" || character.status !== "active" || character.remainingSegments == null) return; | ||
| 788 | + const remainingSegments = Math.max(0, character.remainingSegments - 1); | ||
| 789 | + characters.update(character.id, { remainingSegments, status: remainingSegments === 0 ? "expired" : "active" }); | ||
| 790 | +} | ||
| 791 | + | ||
| 775 | // Consumed entries are spliced out immediately rather than left as | 792 | // Consumed entries are spliced out immediately rather than left as |
| 776 | // status:"scheduled" — once a clip is in `schedule` it shows up via | 793 | // status:"scheduled" — once a clip is in `schedule` it shows up via |
| 777 | // current/upNext, so keeping a second, never-cleared copy in promptQueue | 794 | // current/upNext, so keeping a second, never-cleared copy in promptQueue |
| @@ -1311,7 +1328,16 @@ function pickNextDanceClip() { | @@ -1311,7 +1328,16 @@ function pickNextDanceClip() { | ||
| 1311 | const q = danceQueue.splice(readyIdx, 1)[0]; | 1328 | const q = danceQueue.splice(readyIdx, 1)[0]; |
| 1312 | return { videoId: q.videoId, requestedBy: q.nickname }; | 1329 | return { videoId: q.videoId, requestedBy: q.nickname }; |
| 1313 | } | 1330 | } |
| 1314 | - const pool = getLibrary().filter((c) => c.channel === "dance"); | 1331 | + // T36: a user_paid character whose purchased segments have all played |
| 1332 | + // (status:"expired") drops out of random-fill selection — the record | ||
| 1333 | + // and its videos stay in library.json (M.3/M.7: "记录保留"), just no | ||
| 1334 | + // longer eligible to be picked here. | ||
| 1335 | + const pool = getLibrary().filter((c) => { | ||
| 1336 | + if (c.channel !== "dance") return false; | ||
| 1337 | + if (!c.characterId) return true; | ||
| 1338 | + const character = characters.get(c.characterId); | ||
| 1339 | + return !character || character.status !== "expired"; | ||
| 1340 | + }); | ||
| 1315 | if (!pool.length) return null; | 1341 | if (!pool.length) return null; |
| 1316 | const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null; | 1342 | const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null; |
| 1317 | const clip = pickLeastPlayedClip(pool, prevVideoId); | 1343 | const clip = pickLeastPlayedClip(pool, prevVideoId); |
| @@ -1392,6 +1418,7 @@ function ensureDanceSchedule() { | @@ -1392,6 +1418,7 @@ function ensureDanceSchedule() { | ||
| 1392 | const clip = getLibrary().find((c) => c.id === next.videoId); | 1418 | const clip = getLibrary().find((c) => c.id === next.videoId); |
| 1393 | if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry | 1419 | if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry |
| 1394 | markPlayed(clip.id); | 1420 | markPlayed(clip.id); |
| 1421 | + consumeCharacterSegment(clip); | ||
| 1395 | const insertAt = danceSchedule.length ? 1 : 0; | 1422 | const insertAt = danceSchedule.length ? 1 : 0; |
| 1396 | const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; | 1423 | const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; |
| 1397 | danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); | 1424 | danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); |
| @@ -1407,6 +1434,7 @@ function ensureDanceSchedule() { | @@ -1407,6 +1434,7 @@ function ensureDanceSchedule() { | ||
| 1407 | const clip = getLibrary().find((c) => c.id === picked.videoId); | 1434 | const clip = getLibrary().find((c) => c.id === picked.videoId); |
| 1408 | if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry | 1435 | if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry |
| 1409 | markPlayed(clip.id); | 1436 | markPlayed(clip.id); |
| 1437 | + consumeCharacterSegment(clip); | ||
| 1410 | const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; | 1438 | const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; |
| 1411 | danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); | 1439 | danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); |
| 1412 | } | 1440 | } |