Commit 824ba494fd25129b375d03e913fbd016a71c1a54

Authored by yibo
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
dance-mode3.test.js
... ... @@ -256,6 +256,44 @@ test(&quot;full happy path: cost scales with requested segments, character carries us
256 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 297 test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => {
260 298 // A second, distinct garment so this free pick doesn't hit M.7's
261 299 // dedupe cache against a clip from an earlier test using the same
... ...
server.js
... ... @@ -772,6 +772,23 @@ function markPlayed(id) {
772 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 792 // Consumed entries are spliced out immediately rather than left as
776 793 // status:"scheduled" — once a clip is in `schedule` it shows up via
777 794 // current/upNext, so keeping a second, never-cleared copy in promptQueue
... ... @@ -1311,7 +1328,16 @@ function pickNextDanceClip() {
1311 1328 const q = danceQueue.splice(readyIdx, 1)[0];
1312 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 1341 if (!pool.length) return null;
1316 1342 const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null;
1317 1343 const clip = pickLeastPlayedClip(pool, prevVideoId);
... ... @@ -1392,6 +1418,7 @@ function ensureDanceSchedule() {
1392 1418 const clip = getLibrary().find((c) => c.id === next.videoId);
1393 1419 if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry
1394 1420 markPlayed(clip.id);
  1421 + consumeCharacterSegment(clip);
1395 1422 const insertAt = danceSchedule.length ? 1 : 0;
1396 1423 const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now;
1397 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 1434 const clip = getLibrary().find((c) => c.id === picked.videoId);
1408 1435 if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry
1409 1436 markPlayed(clip.id);
  1437 + consumeCharacterSegment(clip);
1410 1438 const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now;
1411 1439 danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy });
1412 1440 }
... ...