diff --git a/dance-mode3.test.js b/dance-mode3.test.js index 868cced..7ec0002 100644 --- a/dance-mode3.test.js +++ b/dance-mode3.test.js @@ -256,6 +256,44 @@ test("full happy path: cost scales with requested segments, character carries us assert.equal(clipView.garmentId, freeGarment.id); }); +test("T36: a character bought with 1 segment expires after that single play, but its record and video stay queryable", async () => { + const oneOffGarment = await fetch( + `${BASE}/api/admin/garments`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "一次性紫裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "purple dress" }) }) + ).then((r) => r.json()); + + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const uploadRes = await uploadCharacter(BASE, cookie, { garmentId: oneOffGarment.id, segments: 1 }); + assert.equal(uploadRes.status, 200); + const { characterId } = await uploadRes.json(); + + // Scheduling (T34's eager priorityInbox insert) is what consumes the + // segment — same tick that makes it visible in current/upNext, so no + // extra wait beyond that is needed. + await waitUntil(async () => { + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + const all = [s.current, ...(s.upNext || [])].filter(Boolean); + return all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === characterId) ? true : null; + }); + + const expired = await waitUntil(async () => { + const list = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); + const found = list.find((c) => c.id === characterId); + return found && found.status === "expired" ? found : null; + }); + assert.equal(expired.remainingSegments, 0); + assert.equal(expired.totalSegments, 1, "totalSegments is history, unaffected by playing out"); + + // Record/video still retrievable, per 需求文档 3.3 第 7 点 — not deleted, + // just no longer eligible for future random-fill picks (can't easily + // assert the negative here without a long real-time wait, but the pool + // filter in pickNextDanceClip is what dance-mode3's other tests already + // exercise the positive side of). + const clips = await fetch(`${BASE}/api/library`).then((r) => r.json()); + assert.ok(clips.some((c) => c.characterId === characterId), "the generated video must still be retrievable after the character expires"); +}); + test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => { // A second, distinct garment so this free pick doesn't hit M.7's // dedupe cache against a clip from an earlier test using the same diff --git a/server.js b/server.js index 28b49c3..9df53a2 100644 --- a/server.js +++ b/server.js @@ -772,6 +772,23 @@ function markPlayed(id) { saveLibrary(lib); } +// T36 (dev-plan-5.md): dance.html-only, deliberately not folded into the +// shared markPlayed() above — live.html's text-channel clips never have a +// user_paid character behind them, and keeping this explicit (called only +// from the two dance-specific scheduling call sites) matches +// architecture.md 2.1's "two independent product lines" rather than +// silently special-casing the shared helper. A clip's first play already +// happened via T34's priorityInbox insert ("确定性上镜"); every play after +// that — including this same clip recurring through the normal random-fill +// pool — keeps consuming the same purchased quota until it runs out. +function consumeCharacterSegment(clip) { + if (!clip.characterId) return; + const character = characters.get(clip.characterId); + if (!character || character.source !== "user_paid" || character.status !== "active" || character.remainingSegments == null) return; + const remainingSegments = Math.max(0, character.remainingSegments - 1); + characters.update(character.id, { remainingSegments, status: remainingSegments === 0 ? "expired" : "active" }); +} + // Consumed entries are spliced out immediately rather than left as // status:"scheduled" — once a clip is in `schedule` it shows up via // current/upNext, so keeping a second, never-cleared copy in promptQueue @@ -1311,7 +1328,16 @@ function pickNextDanceClip() { const q = danceQueue.splice(readyIdx, 1)[0]; return { videoId: q.videoId, requestedBy: q.nickname }; } - const pool = getLibrary().filter((c) => c.channel === "dance"); + // T36: a user_paid character whose purchased segments have all played + // (status:"expired") drops out of random-fill selection — the record + // and its videos stay in library.json (M.3/M.7: "记录保留"), just no + // longer eligible to be picked here. + const pool = getLibrary().filter((c) => { + if (c.channel !== "dance") return false; + if (!c.characterId) return true; + const character = characters.get(c.characterId); + return !character || character.status !== "expired"; + }); if (!pool.length) return null; const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null; const clip = pickLeastPlayedClip(pool, prevVideoId); @@ -1392,6 +1418,7 @@ function ensureDanceSchedule() { const clip = getLibrary().find((c) => c.id === next.videoId); if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry markPlayed(clip.id); + consumeCharacterSegment(clip); const insertAt = danceSchedule.length ? 1 : 0; const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); @@ -1407,6 +1434,7 @@ function ensureDanceSchedule() { const clip = getLibrary().find((c) => c.id === picked.videoId); if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry markPlayed(clip.id); + consumeCharacterSegment(clip); const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); }