cortico 0.1.0
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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- package/src/worlds/websearch/world.ts +171 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { estimateTokens } from '../../core/util.ts';
|
|
12
|
+
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const MINUTE_MS = 60 * 1000;
|
|
15
|
+
const LEDGER_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
const AUDIENCE_ADMISSION_DEFAULTS = {
|
|
18
|
+
superchatSingleYuan: 30,
|
|
19
|
+
superchatRollingYuan: 50,
|
|
20
|
+
superchatWindowMs: 30 * DAY_MS,
|
|
21
|
+
superchatLeaseMs: 30 * DAY_MS,
|
|
22
|
+
guardRetentionMs: 35 * DAY_MS,
|
|
23
|
+
interactionWindowMs: 30 * DAY_MS,
|
|
24
|
+
interactionLeaseMs: 30 * DAY_MS,
|
|
25
|
+
/**
|
|
26
|
+
* 互动资格按每场去重活跃分钟计量,不按在线时长计量。所需分钟数和场次数由每个 bot 的 tuning 配置。
|
|
27
|
+
*/
|
|
28
|
+
interactionMinutesPerStream: 25,
|
|
29
|
+
interactionStreamCount: 1,
|
|
30
|
+
onlineRankCrowdedOn: 200,
|
|
31
|
+
onlineRankCrowdedOff: 170,
|
|
32
|
+
onlineRankReleaseMs: 120_000,
|
|
33
|
+
onlineRankFreshMs: 90_000,
|
|
34
|
+
onlineRankStaleHoldMs: 300_000,
|
|
35
|
+
lineBudget: 114,
|
|
36
|
+
tokenBudget: 1061,
|
|
37
|
+
importantBudgetShare: 0.5,
|
|
38
|
+
persistIntervalMs: 30_000,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export type AudienceAdmissionTuning = {
|
|
42
|
+
-readonly [K in keyof typeof AUDIENCE_ADMISSION_DEFAULTS]: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type AudienceImportanceReason = 'guard' | 'superchat' | 'interaction';
|
|
46
|
+
type AudienceAdmissionLane = 'critical' | 'important' | 'ordinary';
|
|
47
|
+
|
|
48
|
+
interface AudienceAdmissionObservation {
|
|
49
|
+
/** Bilibili numeric UID. Anonymous UID 0 is ignored. */
|
|
50
|
+
senderKey: string;
|
|
51
|
+
at?: number;
|
|
52
|
+
interaction?: boolean;
|
|
53
|
+
superchatYuan?: number;
|
|
54
|
+
/** A buy, renewal, or roster observation with positive membership evidence. */
|
|
55
|
+
guard?: boolean;
|
|
56
|
+
guardLevel?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AudienceStreamIdentity {
|
|
60
|
+
roomId?: number;
|
|
61
|
+
liveStartedAt?: number | string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AudienceAdmissionCandidate<TMeta = unknown> {
|
|
65
|
+
/** Stable source identity, normally the contributing event cursors. */
|
|
66
|
+
stableKey: string;
|
|
67
|
+
text: string;
|
|
68
|
+
type: string;
|
|
69
|
+
senderKeys: readonly string[];
|
|
70
|
+
critical?: boolean;
|
|
71
|
+
meta?: TMeta;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ImportantAudienceParticipant {
|
|
75
|
+
senderKey: string;
|
|
76
|
+
reasons: readonly AudienceImportanceReason[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SelectedAudienceCandidate<TCandidate extends AudienceAdmissionCandidate = AudienceAdmissionCandidate> {
|
|
80
|
+
index: number;
|
|
81
|
+
candidate: TCandidate;
|
|
82
|
+
lane: AudienceAdmissionLane;
|
|
83
|
+
importantParticipants: readonly ImportantAudienceParticipant[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface AudienceLaneMetrics {
|
|
87
|
+
input: number;
|
|
88
|
+
selected: number;
|
|
89
|
+
inputTokens: number;
|
|
90
|
+
selectedTokens: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface AudienceAdmissionBatchMetrics {
|
|
94
|
+
limitingActive: boolean;
|
|
95
|
+
overloaded: boolean;
|
|
96
|
+
criticalOverflow: boolean;
|
|
97
|
+
input: number;
|
|
98
|
+
selected: number;
|
|
99
|
+
dropped: number;
|
|
100
|
+
inputLines: number;
|
|
101
|
+
selectedLines: number;
|
|
102
|
+
inputTokens: number;
|
|
103
|
+
selectedTokens: number;
|
|
104
|
+
lineBudget: number;
|
|
105
|
+
tokenBudget: number;
|
|
106
|
+
importantViewers: number;
|
|
107
|
+
crowd: AudienceCrowdMetrics;
|
|
108
|
+
lanes: Record<AudienceAdmissionLane, AudienceLaneMetrics>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface AudienceCrowdMetrics {
|
|
112
|
+
onlineRankCount: number | null;
|
|
113
|
+
signalAgeMs: number | null;
|
|
114
|
+
signalFresh: boolean;
|
|
115
|
+
active: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface AudienceAdmissionProjection<TCandidate extends AudienceAdmissionCandidate = AudienceAdmissionCandidate> {
|
|
119
|
+
selected: readonly SelectedAudienceCandidate<TCandidate>[];
|
|
120
|
+
metrics: AudienceAdmissionBatchMetrics;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface AudienceAdmissionSnapshot {
|
|
124
|
+
streamOpen: boolean;
|
|
125
|
+
trackedViewers: number;
|
|
126
|
+
qualifiedViewers: number;
|
|
127
|
+
crowd: AudienceCrowdMetrics;
|
|
128
|
+
totals: {
|
|
129
|
+
batches: number;
|
|
130
|
+
limitedBatches: number;
|
|
131
|
+
input: number;
|
|
132
|
+
selected: number;
|
|
133
|
+
dropped: number;
|
|
134
|
+
inputTokens: number;
|
|
135
|
+
selectedTokens: number;
|
|
136
|
+
};
|
|
137
|
+
lastBatch: AudienceAdmissionBatchMetrics | null;
|
|
138
|
+
persistenceError: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface AudienceAdmissionOptions {
|
|
142
|
+
file?: string;
|
|
143
|
+
roomId: number;
|
|
144
|
+
tuning?: Partial<AudienceAdmissionTuning>;
|
|
145
|
+
now?: () => number;
|
|
146
|
+
createId?: () => string;
|
|
147
|
+
onPersistError?: (error: Error) => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface SuperchatEntry {
|
|
151
|
+
at: number;
|
|
152
|
+
cents: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface InteractionStream {
|
|
156
|
+
lastAt: number;
|
|
157
|
+
minutes: number[];
|
|
158
|
+
qualifiedAt?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ViewerLedger {
|
|
162
|
+
lastSeenAt: number;
|
|
163
|
+
guardExpiresAt?: number;
|
|
164
|
+
superchatExpiresAt?: number;
|
|
165
|
+
superchats: SuperchatEntry[];
|
|
166
|
+
interactionExpiresAt?: number;
|
|
167
|
+
interactionStreams: Record<string, InteractionStream>;
|
|
168
|
+
fairDebt: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface OpenStream {
|
|
172
|
+
roomId: number;
|
|
173
|
+
id: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface AudienceLedgerFile {
|
|
177
|
+
schemaVersion: 1;
|
|
178
|
+
salt: string;
|
|
179
|
+
openStream: OpenStream | null;
|
|
180
|
+
viewers: Record<string, ViewerLedger>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ClassifiedCandidate<TCandidate extends AudienceAdmissionCandidate> {
|
|
184
|
+
index: number;
|
|
185
|
+
candidate: TCandidate;
|
|
186
|
+
lane: AudienceAdmissionLane;
|
|
187
|
+
importantParticipants: ImportantAudienceParticipant[];
|
|
188
|
+
lines: number;
|
|
189
|
+
tokens: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface SelectionUsage {
|
|
193
|
+
lines: number;
|
|
194
|
+
tokens: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ZERO_TOTALS = {
|
|
198
|
+
batches: 0,
|
|
199
|
+
limitedBatches: 0,
|
|
200
|
+
input: 0,
|
|
201
|
+
selected: 0,
|
|
202
|
+
dropped: 0,
|
|
203
|
+
inputTokens: 0,
|
|
204
|
+
selectedTokens: 0,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Private Bilibili audience ledger and delivery-time admission controller.
|
|
209
|
+
* Projection reads and updates memory synchronously; persistence runs outside the projection call.
|
|
210
|
+
*/
|
|
211
|
+
export class AudienceAdmission {
|
|
212
|
+
readonly tuning: AudienceAdmissionTuning;
|
|
213
|
+
|
|
214
|
+
private readonly file?: string;
|
|
215
|
+
private readonly roomId: number;
|
|
216
|
+
private readonly now: () => number;
|
|
217
|
+
private readonly createId: () => string;
|
|
218
|
+
private readonly onPersistError?: (error: Error) => void;
|
|
219
|
+
private ledger: AudienceLedgerFile;
|
|
220
|
+
private dirty = false;
|
|
221
|
+
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
222
|
+
private lastMaintenanceAt = 0;
|
|
223
|
+
private onlineRankCount: number | null = null;
|
|
224
|
+
private onlineRankObservedAt: number | null = null;
|
|
225
|
+
private crowded = false;
|
|
226
|
+
private belowReleaseSince: number | null = null;
|
|
227
|
+
private totals = { ...ZERO_TOTALS };
|
|
228
|
+
private lastBatch: AudienceAdmissionBatchMetrics | null = null;
|
|
229
|
+
private persistenceError: string | null = null;
|
|
230
|
+
|
|
231
|
+
constructor(options: AudienceAdmissionOptions) {
|
|
232
|
+
this.file = options.file;
|
|
233
|
+
this.roomId = options.roomId;
|
|
234
|
+
this.now = options.now ?? Date.now;
|
|
235
|
+
this.createId = options.createId ?? randomUUID;
|
|
236
|
+
this.onPersistError = options.onPersistError;
|
|
237
|
+
this.tuning = validateTuning({ ...AUDIENCE_ADMISSION_DEFAULTS, ...options.tuning });
|
|
238
|
+
const loaded = this.load();
|
|
239
|
+
this.ledger = loaded ?? this.freshLedger();
|
|
240
|
+
if (!loaded) this.markDirty();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
startStream(identity: AudienceStreamIdentity = {}): string {
|
|
244
|
+
const roomId = identity.roomId ?? this.roomId;
|
|
245
|
+
const started = normalizedStart(identity.liveStartedAt);
|
|
246
|
+
if (started !== null) {
|
|
247
|
+
const id = `${roomId}:${started}`;
|
|
248
|
+
if (this.ledger.openStream?.id !== id) {
|
|
249
|
+
const previous = this.ledger.openStream;
|
|
250
|
+
if (previous?.roomId === roomId && previous.id.startsWith(`${roomId}:local:`)) {
|
|
251
|
+
this.renameInteractionStream(previous.id, id);
|
|
252
|
+
} else if (previous) {
|
|
253
|
+
this.resetCrowdSignal();
|
|
254
|
+
}
|
|
255
|
+
this.ledger.openStream = { roomId, id };
|
|
256
|
+
this.markDirty();
|
|
257
|
+
}
|
|
258
|
+
return id;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const current = this.ledger.openStream;
|
|
262
|
+
if (current?.roomId === roomId) return current.id;
|
|
263
|
+
if (current) this.resetCrowdSignal();
|
|
264
|
+
const id = `${roomId}:local:${this.createId()}`;
|
|
265
|
+
this.ledger.openStream = { roomId, id };
|
|
266
|
+
this.markDirty();
|
|
267
|
+
return id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
endStream(): void {
|
|
271
|
+
this.resetCrowdSignal();
|
|
272
|
+
if (this.ledger.openStream) {
|
|
273
|
+
this.ledger.openStream = null;
|
|
274
|
+
this.markDirty();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
updateTuning(partial: Partial<AudienceAdmissionTuning>): AudienceAdmissionTuning {
|
|
279
|
+
const next = validateTuning({ ...this.tuning, ...partial });
|
|
280
|
+
const changed = (Object.keys(next) as Array<keyof AudienceAdmissionTuning>)
|
|
281
|
+
.some((key) => next[key] !== this.tuning[key]);
|
|
282
|
+
if (!changed) return { ...this.tuning };
|
|
283
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
284
|
+
this.saveTimer = null;
|
|
285
|
+
Object.assign(this.tuning, next);
|
|
286
|
+
|
|
287
|
+
if (this.onlineRankCount !== null) {
|
|
288
|
+
if (this.onlineRankCount >= this.tuning.onlineRankCrowdedOn) {
|
|
289
|
+
this.crowded = true;
|
|
290
|
+
this.belowReleaseSince = null;
|
|
291
|
+
} else if (this.crowded && this.onlineRankCount <= this.tuning.onlineRankCrowdedOff) {
|
|
292
|
+
this.belowReleaseSince ??= this.now();
|
|
293
|
+
} else if (this.onlineRankCount > this.tuning.onlineRankCrowdedOff) {
|
|
294
|
+
this.belowReleaseSince = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (this.dirty) this.markDirty();
|
|
298
|
+
return { ...this.tuning };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
observe(observation: AudienceAdmissionObservation): void {
|
|
302
|
+
const senderKey = uidOf(observation.senderKey);
|
|
303
|
+
if (!senderKey) return;
|
|
304
|
+
const at = timestampOf(observation.at ?? this.now());
|
|
305
|
+
this.maintain(at);
|
|
306
|
+
const viewer = this.viewer(senderKey, at);
|
|
307
|
+
viewer.lastSeenAt = Math.max(viewer.lastSeenAt, at);
|
|
308
|
+
|
|
309
|
+
if (observation.interaction) this.noteInteraction(viewer, at);
|
|
310
|
+
if (observation.superchatYuan !== undefined) {
|
|
311
|
+
this.noteSuperchat(viewer, observation.superchatYuan, at);
|
|
312
|
+
}
|
|
313
|
+
if (observation.guard === true || (observation.guardLevel ?? 0) > 0) {
|
|
314
|
+
viewer.guardExpiresAt = Math.max(
|
|
315
|
+
viewer.guardExpiresAt ?? 0,
|
|
316
|
+
at + this.tuning.guardRetentionMs,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
this.markDirty();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
observeOnlineRank(count: number, at = this.now()): void {
|
|
323
|
+
at = timestampOf(at);
|
|
324
|
+
if (!Number.isFinite(count) || count < 0) throw new Error('online rank count must be non-negative');
|
|
325
|
+
count = Math.floor(count);
|
|
326
|
+
|
|
327
|
+
if (
|
|
328
|
+
this.onlineRankObservedAt !== null &&
|
|
329
|
+
at - this.onlineRankObservedAt > this.tuning.onlineRankStaleHoldMs
|
|
330
|
+
) {
|
|
331
|
+
this.crowded = false;
|
|
332
|
+
this.belowReleaseSince = null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.onlineRankCount = count;
|
|
336
|
+
this.onlineRankObservedAt = at;
|
|
337
|
+
if (count >= this.tuning.onlineRankCrowdedOn) {
|
|
338
|
+
this.crowded = true;
|
|
339
|
+
this.belowReleaseSince = null;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!this.crowded) return;
|
|
343
|
+
if (count > this.tuning.onlineRankCrowdedOff) {
|
|
344
|
+
this.belowReleaseSince = null;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (this.belowReleaseSince === null) {
|
|
348
|
+
this.belowReleaseSince = at;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (at - this.belowReleaseSince >= this.tuning.onlineRankReleaseMs) {
|
|
352
|
+
this.crowded = false;
|
|
353
|
+
this.belowReleaseSince = null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
importanceOf(senderKey: string, at = this.now()): ImportantAudienceParticipant | null {
|
|
358
|
+
const uid = uidOf(senderKey);
|
|
359
|
+
if (!uid) return null;
|
|
360
|
+
const viewer = this.ledger.viewers[uid];
|
|
361
|
+
if (!viewer) return null;
|
|
362
|
+
const reasons: AudienceImportanceReason[] = [];
|
|
363
|
+
if ((viewer.guardExpiresAt ?? 0) > at) reasons.push('guard');
|
|
364
|
+
if ((viewer.superchatExpiresAt ?? 0) > at) reasons.push('superchat');
|
|
365
|
+
if ((viewer.interactionExpiresAt ?? 0) > at) reasons.push('interaction');
|
|
366
|
+
return reasons.length > 0 ? { senderKey: uid, reasons } : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
project<TCandidate extends AudienceAdmissionCandidate>(
|
|
370
|
+
candidates: readonly TCandidate[],
|
|
371
|
+
at = this.now(),
|
|
372
|
+
): AudienceAdmissionProjection<TCandidate> {
|
|
373
|
+
at = timestampOf(at);
|
|
374
|
+
const classified = candidates.map((candidate, index) => this.classify(candidate, index, at));
|
|
375
|
+
const inputLines = classified.reduce((sum, item) => sum + item.lines, 0);
|
|
376
|
+
const inputTokens = classified.reduce((sum, item) => sum + item.tokens, 0);
|
|
377
|
+
const overloaded = inputLines > this.tuning.lineBudget || inputTokens > this.tuning.tokenBudget;
|
|
378
|
+
const crowd = this.crowdMetrics(at);
|
|
379
|
+
const limitingActive = crowd.active && overloaded;
|
|
380
|
+
|
|
381
|
+
const selected = limitingActive
|
|
382
|
+
? this.selectLimited(classified)
|
|
383
|
+
: classified.map((item) => this.asSelected(item));
|
|
384
|
+
selected.sort((a, b) => a.index - b.index);
|
|
385
|
+
|
|
386
|
+
const selectedLines = selected.reduce((sum, item) => sum + classified[item.index]!.lines, 0);
|
|
387
|
+
const selectedTokens = selected.reduce((sum, item) => sum + classified[item.index]!.tokens, 0);
|
|
388
|
+
const metrics = this.batchMetrics(
|
|
389
|
+
classified,
|
|
390
|
+
selected,
|
|
391
|
+
crowd,
|
|
392
|
+
limitingActive,
|
|
393
|
+
overloaded,
|
|
394
|
+
inputLines,
|
|
395
|
+
inputTokens,
|
|
396
|
+
selectedLines,
|
|
397
|
+
selectedTokens,
|
|
398
|
+
);
|
|
399
|
+
this.lastBatch = metrics;
|
|
400
|
+
this.totals.batches += 1;
|
|
401
|
+
if (limitingActive) this.totals.limitedBatches += 1;
|
|
402
|
+
this.totals.input += candidates.length;
|
|
403
|
+
this.totals.selected += selected.length;
|
|
404
|
+
this.totals.dropped += candidates.length - selected.length;
|
|
405
|
+
this.totals.inputTokens += inputTokens;
|
|
406
|
+
this.totals.selectedTokens += selectedTokens;
|
|
407
|
+
|
|
408
|
+
return { selected, metrics };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
snapshot(at = this.now()): AudienceAdmissionSnapshot {
|
|
412
|
+
let qualifiedViewers = 0;
|
|
413
|
+
for (const senderKey of Object.keys(this.ledger.viewers)) {
|
|
414
|
+
if (this.importanceOf(senderKey, at)) qualifiedViewers += 1;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
streamOpen: this.ledger.openStream !== null,
|
|
418
|
+
trackedViewers: Object.keys(this.ledger.viewers).length,
|
|
419
|
+
qualifiedViewers,
|
|
420
|
+
crowd: this.crowdMetrics(at),
|
|
421
|
+
totals: { ...this.totals },
|
|
422
|
+
lastBatch: this.lastBatch,
|
|
423
|
+
persistenceError: this.persistenceError,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
flush(): void {
|
|
428
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
429
|
+
this.saveTimer = null;
|
|
430
|
+
if (!this.dirty || !this.file) return;
|
|
431
|
+
try {
|
|
432
|
+
persistLedger(this.file, this.ledger);
|
|
433
|
+
this.dirty = false;
|
|
434
|
+
this.persistenceError = null;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
437
|
+
this.persistenceError = failure.message;
|
|
438
|
+
throw failure;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
stop(): void {
|
|
443
|
+
this.flush();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private classify<TCandidate extends AudienceAdmissionCandidate>(
|
|
447
|
+
candidate: TCandidate,
|
|
448
|
+
index: number,
|
|
449
|
+
at: number,
|
|
450
|
+
): ClassifiedCandidate<TCandidate> {
|
|
451
|
+
const importantParticipants = uniqueUids(candidate.senderKeys)
|
|
452
|
+
.map((senderKey) => this.importanceOf(senderKey, at))
|
|
453
|
+
.filter((participant): participant is ImportantAudienceParticipant => participant !== null);
|
|
454
|
+
return {
|
|
455
|
+
index,
|
|
456
|
+
candidate,
|
|
457
|
+
lane: candidate.critical
|
|
458
|
+
? 'critical'
|
|
459
|
+
: importantParticipants.length > 0
|
|
460
|
+
? 'important'
|
|
461
|
+
: 'ordinary',
|
|
462
|
+
importantParticipants,
|
|
463
|
+
lines: lineCount(candidate.text),
|
|
464
|
+
tokens: estimateTokens(candidate.text),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private selectLimited<TCandidate extends AudienceAdmissionCandidate>(
|
|
469
|
+
items: readonly ClassifiedCandidate<TCandidate>[],
|
|
470
|
+
): SelectedAudienceCandidate<TCandidate>[] {
|
|
471
|
+
const selected = new Set<number>();
|
|
472
|
+
const usage: SelectionUsage = { lines: 0, tokens: 0 };
|
|
473
|
+
for (const item of items) {
|
|
474
|
+
if (item.lane !== 'critical') continue;
|
|
475
|
+
selected.add(item.index);
|
|
476
|
+
usage.lines += item.lines;
|
|
477
|
+
usage.tokens += item.tokens;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const important = items.filter((item) => item.lane === 'important');
|
|
481
|
+
const importantUsage: SelectionUsage = { lines: 0, tokens: 0 };
|
|
482
|
+
const importantLimit: SelectionUsage = {
|
|
483
|
+
lines: Math.floor(this.tuning.lineBudget * this.tuning.importantBudgetShare),
|
|
484
|
+
tokens: Math.floor(this.tuning.tokenBudget * this.tuning.importantBudgetShare),
|
|
485
|
+
};
|
|
486
|
+
const activeUids = new Set(important.flatMap((item) => item.importantParticipants.map((p) => p.senderKey)));
|
|
487
|
+
for (const uid of activeUids) {
|
|
488
|
+
const viewer = this.ledger.viewers[uid]!;
|
|
489
|
+
viewer.fairDebt = Math.min(1000, viewer.fairDebt + 1);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const uidOrder = [...activeUids].sort((a, b) => {
|
|
493
|
+
const debt = this.ledger.viewers[b]!.fairDebt - this.ledger.viewers[a]!.fairDebt;
|
|
494
|
+
return debt || this.rank(`important-viewer:${a}`).localeCompare(this.rank(`important-viewer:${b}`));
|
|
495
|
+
});
|
|
496
|
+
const served = new Set<string>();
|
|
497
|
+
for (const uid of uidOrder) {
|
|
498
|
+
if (served.has(uid)) continue;
|
|
499
|
+
const choices = important
|
|
500
|
+
.filter((item) => !selected.has(item.index) && item.importantParticipants.some((p) => p.senderKey === uid))
|
|
501
|
+
.sort((a, b) => this.itemRank(a).localeCompare(this.itemRank(b)) || a.index - b.index);
|
|
502
|
+
const choice = choices.find((item) => this.fits(item, usage, importantUsage, importantLimit));
|
|
503
|
+
if (!choice) continue;
|
|
504
|
+
this.take(choice, selected, usage, importantUsage);
|
|
505
|
+
for (const participant of choice.importantParticipants) served.add(participant.senderKey);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const importantRemainder = important
|
|
509
|
+
.filter((item) => !selected.has(item.index))
|
|
510
|
+
.sort((a, b) => {
|
|
511
|
+
const debt = maxDebt(b, this.ledger.viewers) - maxDebt(a, this.ledger.viewers);
|
|
512
|
+
return debt || this.itemRank(a).localeCompare(this.itemRank(b)) || a.index - b.index;
|
|
513
|
+
});
|
|
514
|
+
for (const item of importantRemainder) {
|
|
515
|
+
if (this.fits(item, usage, importantUsage, importantLimit)) {
|
|
516
|
+
this.take(item, selected, usage, importantUsage);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const index of selected) {
|
|
521
|
+
const item = items[index]!;
|
|
522
|
+
if (item.lane !== 'important') continue;
|
|
523
|
+
for (const participant of item.importantParticipants) {
|
|
524
|
+
const viewer = this.ledger.viewers[participant.senderKey]!;
|
|
525
|
+
viewer.fairDebt = Math.max(-1000, viewer.fairDebt - 1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (activeUids.size > 0) this.markDirty();
|
|
529
|
+
|
|
530
|
+
const ordinary = items.filter((item) => item.lane === 'ordinary');
|
|
531
|
+
const buckets = new Map<string, ClassifiedCandidate<TCandidate>[]>();
|
|
532
|
+
for (const item of ordinary) {
|
|
533
|
+
const participants = uniqueUids(item.candidate.senderKeys);
|
|
534
|
+
const bucket = participants.length > 0
|
|
535
|
+
? `viewers:${participants.join(',')}`
|
|
536
|
+
: `event:${item.candidate.stableKey}`;
|
|
537
|
+
const entries = buckets.get(bucket);
|
|
538
|
+
if (entries) entries.push(item);
|
|
539
|
+
else buckets.set(bucket, [item]);
|
|
540
|
+
}
|
|
541
|
+
const bucketOrder = [...buckets.entries()].sort((a, b) =>
|
|
542
|
+
this.rank(`ordinary-bucket:${a[0]}`).localeCompare(this.rank(`ordinary-bucket:${b[0]}`)),
|
|
543
|
+
);
|
|
544
|
+
for (const [, entries] of bucketOrder) {
|
|
545
|
+
entries.sort((a, b) => this.itemRank(a).localeCompare(this.itemRank(b)) || a.index - b.index);
|
|
546
|
+
const choice = entries.find((item) => !selected.has(item.index) && this.fitsTotal(item, usage));
|
|
547
|
+
if (choice) this.take(choice, selected, usage);
|
|
548
|
+
}
|
|
549
|
+
const ordinaryRemainder = ordinary
|
|
550
|
+
.filter((item) => !selected.has(item.index))
|
|
551
|
+
.sort((a, b) => this.itemRank(a).localeCompare(this.itemRank(b)) || a.index - b.index);
|
|
552
|
+
for (const item of ordinaryRemainder) {
|
|
553
|
+
if (this.fitsTotal(item, usage)) this.take(item, selected, usage);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return [...selected].map((index) => this.asSelected(items[index]!));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fits<TCandidate extends AudienceAdmissionCandidate>(
|
|
560
|
+
item: ClassifiedCandidate<TCandidate>,
|
|
561
|
+
usage: SelectionUsage,
|
|
562
|
+
laneUsage: SelectionUsage,
|
|
563
|
+
laneLimit: SelectionUsage,
|
|
564
|
+
): boolean {
|
|
565
|
+
return this.fitsTotal(item, usage) &&
|
|
566
|
+
laneUsage.lines + item.lines <= laneLimit.lines &&
|
|
567
|
+
laneUsage.tokens + item.tokens <= laneLimit.tokens;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private fitsTotal<TCandidate extends AudienceAdmissionCandidate>(
|
|
571
|
+
item: ClassifiedCandidate<TCandidate>,
|
|
572
|
+
usage: SelectionUsage,
|
|
573
|
+
): boolean {
|
|
574
|
+
return usage.lines + item.lines <= this.tuning.lineBudget &&
|
|
575
|
+
usage.tokens + item.tokens <= this.tuning.tokenBudget;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private take<TCandidate extends AudienceAdmissionCandidate>(
|
|
579
|
+
item: ClassifiedCandidate<TCandidate>,
|
|
580
|
+
selected: Set<number>,
|
|
581
|
+
usage: SelectionUsage,
|
|
582
|
+
laneUsage?: SelectionUsage,
|
|
583
|
+
): void {
|
|
584
|
+
selected.add(item.index);
|
|
585
|
+
usage.lines += item.lines;
|
|
586
|
+
usage.tokens += item.tokens;
|
|
587
|
+
if (laneUsage) {
|
|
588
|
+
laneUsage.lines += item.lines;
|
|
589
|
+
laneUsage.tokens += item.tokens;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private asSelected<TCandidate extends AudienceAdmissionCandidate>(
|
|
594
|
+
item: ClassifiedCandidate<TCandidate>,
|
|
595
|
+
): SelectedAudienceCandidate<TCandidate> {
|
|
596
|
+
return {
|
|
597
|
+
index: item.index,
|
|
598
|
+
candidate: item.candidate,
|
|
599
|
+
lane: item.lane,
|
|
600
|
+
importantParticipants: item.importantParticipants,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private itemRank<TCandidate extends AudienceAdmissionCandidate>(item: ClassifiedCandidate<TCandidate>): string {
|
|
605
|
+
return this.rank([
|
|
606
|
+
item.candidate.stableKey,
|
|
607
|
+
item.candidate.type,
|
|
608
|
+
uniqueUids(item.candidate.senderKeys).join(','),
|
|
609
|
+
].join('|'));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private rank(value: string): string {
|
|
613
|
+
const stream = this.ledger.openStream?.id ?? `${this.roomId}:closed`;
|
|
614
|
+
return createHash('sha256').update(this.ledger.salt).update('\0').update(stream).update('\0').update(value).digest('hex');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private batchMetrics<TCandidate extends AudienceAdmissionCandidate>(
|
|
618
|
+
input: readonly ClassifiedCandidate<TCandidate>[],
|
|
619
|
+
selected: readonly SelectedAudienceCandidate<TCandidate>[],
|
|
620
|
+
crowd: AudienceCrowdMetrics,
|
|
621
|
+
limitingActive: boolean,
|
|
622
|
+
overloaded: boolean,
|
|
623
|
+
inputLines: number,
|
|
624
|
+
inputTokens: number,
|
|
625
|
+
selectedLines: number,
|
|
626
|
+
selectedTokens: number,
|
|
627
|
+
): AudienceAdmissionBatchMetrics {
|
|
628
|
+
const selectedIndexes = new Set(selected.map((item) => item.index));
|
|
629
|
+
const lanes = Object.fromEntries((['critical', 'important', 'ordinary'] as const).map((lane) => [
|
|
630
|
+
lane,
|
|
631
|
+
{
|
|
632
|
+
input: input.filter((item) => item.lane === lane).length,
|
|
633
|
+
selected: input.filter((item) => item.lane === lane && selectedIndexes.has(item.index)).length,
|
|
634
|
+
inputTokens: input
|
|
635
|
+
.filter((item) => item.lane === lane)
|
|
636
|
+
.reduce((sum, item) => sum + item.tokens, 0),
|
|
637
|
+
selectedTokens: input
|
|
638
|
+
.filter((item) => item.lane === lane && selectedIndexes.has(item.index))
|
|
639
|
+
.reduce((sum, item) => sum + item.tokens, 0),
|
|
640
|
+
},
|
|
641
|
+
])) as Record<AudienceAdmissionLane, AudienceLaneMetrics>;
|
|
642
|
+
const importantViewers = new Set(input.flatMap((item) =>
|
|
643
|
+
item.importantParticipants.map((participant) => participant.senderKey),
|
|
644
|
+
)).size;
|
|
645
|
+
const criticalLines = input
|
|
646
|
+
.filter((item) => item.lane === 'critical')
|
|
647
|
+
.reduce((sum, item) => sum + item.lines, 0);
|
|
648
|
+
const criticalTokens = input
|
|
649
|
+
.filter((item) => item.lane === 'critical')
|
|
650
|
+
.reduce((sum, item) => sum + item.tokens, 0);
|
|
651
|
+
return {
|
|
652
|
+
limitingActive,
|
|
653
|
+
overloaded,
|
|
654
|
+
criticalOverflow: criticalLines > this.tuning.lineBudget || criticalTokens > this.tuning.tokenBudget,
|
|
655
|
+
input: input.length,
|
|
656
|
+
selected: selected.length,
|
|
657
|
+
dropped: input.length - selected.length,
|
|
658
|
+
inputLines,
|
|
659
|
+
selectedLines,
|
|
660
|
+
inputTokens,
|
|
661
|
+
selectedTokens,
|
|
662
|
+
lineBudget: this.tuning.lineBudget,
|
|
663
|
+
tokenBudget: this.tuning.tokenBudget,
|
|
664
|
+
importantViewers,
|
|
665
|
+
crowd,
|
|
666
|
+
lanes,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private crowdMetrics(at: number): AudienceCrowdMetrics {
|
|
671
|
+
const age = this.onlineRankObservedAt === null ? null : Math.max(0, at - this.onlineRankObservedAt);
|
|
672
|
+
return {
|
|
673
|
+
onlineRankCount: this.onlineRankCount,
|
|
674
|
+
signalAgeMs: age,
|
|
675
|
+
signalFresh: age !== null && age <= this.tuning.onlineRankFreshMs,
|
|
676
|
+
active: this.crowded && age !== null && age <= this.tuning.onlineRankStaleHoldMs,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private resetCrowdSignal(): void {
|
|
681
|
+
this.onlineRankCount = null;
|
|
682
|
+
this.onlineRankObservedAt = null;
|
|
683
|
+
this.crowded = false;
|
|
684
|
+
this.belowReleaseSince = null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private noteSuperchat(viewer: ViewerLedger, yuan: number, at: number): void {
|
|
688
|
+
if (!Number.isFinite(yuan) || yuan < 0) throw new Error('superchat amount must be non-negative');
|
|
689
|
+
const cents = Math.round(yuan * 100);
|
|
690
|
+
const cutoff = at - this.tuning.superchatWindowMs;
|
|
691
|
+
viewer.superchats = viewer.superchats.filter((entry) => entry.at >= cutoff);
|
|
692
|
+
if (cents > 0) viewer.superchats.push({ at, cents });
|
|
693
|
+
const rollingCents = viewer.superchats.reduce((sum, entry) => sum + entry.cents, 0);
|
|
694
|
+
if (
|
|
695
|
+
cents >= Math.round(this.tuning.superchatSingleYuan * 100) ||
|
|
696
|
+
rollingCents >= Math.round(this.tuning.superchatRollingYuan * 100)
|
|
697
|
+
) {
|
|
698
|
+
viewer.superchatExpiresAt = Math.max(
|
|
699
|
+
viewer.superchatExpiresAt ?? 0,
|
|
700
|
+
at + this.tuning.superchatLeaseMs,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private noteInteraction(viewer: ViewerLedger, at: number): void {
|
|
706
|
+
const streamId = this.ledger.openStream?.id ?? this.startStream();
|
|
707
|
+
let stream = viewer.interactionStreams[streamId];
|
|
708
|
+
if (!stream) {
|
|
709
|
+
stream = { lastAt: at, minutes: [] };
|
|
710
|
+
viewer.interactionStreams[streamId] = stream;
|
|
711
|
+
}
|
|
712
|
+
stream.lastAt = Math.max(stream.lastAt, at);
|
|
713
|
+
if (stream.qualifiedAt !== undefined) return;
|
|
714
|
+
const minute = Math.floor(at / MINUTE_MS);
|
|
715
|
+
if (!stream.minutes.includes(minute)) stream.minutes.push(minute);
|
|
716
|
+
if (stream.minutes.length < this.tuning.interactionMinutesPerStream) return;
|
|
717
|
+
|
|
718
|
+
stream.qualifiedAt = at;
|
|
719
|
+
stream.minutes = [];
|
|
720
|
+
const cutoff = at - this.tuning.interactionWindowMs;
|
|
721
|
+
const qualifiedStreams = Object.values(viewer.interactionStreams)
|
|
722
|
+
.filter((entry) => (entry.qualifiedAt ?? -Infinity) >= cutoff)
|
|
723
|
+
.length;
|
|
724
|
+
if (qualifiedStreams >= this.tuning.interactionStreamCount) {
|
|
725
|
+
viewer.interactionExpiresAt = Math.max(
|
|
726
|
+
viewer.interactionExpiresAt ?? 0,
|
|
727
|
+
at + this.tuning.interactionLeaseMs,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** 开播推送先到、平台开播时刻后到时,两个键仍是同一场直播。 */
|
|
733
|
+
private renameInteractionStream(from: string, to: string): void {
|
|
734
|
+
for (const viewer of Object.values(this.ledger.viewers)) {
|
|
735
|
+
const source = viewer.interactionStreams[from];
|
|
736
|
+
if (!source) continue;
|
|
737
|
+
const target = viewer.interactionStreams[to];
|
|
738
|
+
if (!target) {
|
|
739
|
+
viewer.interactionStreams[to] = source;
|
|
740
|
+
} else {
|
|
741
|
+
target.lastAt = Math.max(target.lastAt, source.lastAt);
|
|
742
|
+
target.minutes = [...new Set([...target.minutes, ...source.minutes])].sort((a, b) => a - b);
|
|
743
|
+
const qualified = [target.qualifiedAt, source.qualifiedAt]
|
|
744
|
+
.filter((value): value is number => value !== undefined);
|
|
745
|
+
if (qualified.length > 0) target.qualifiedAt = Math.min(...qualified);
|
|
746
|
+
}
|
|
747
|
+
delete viewer.interactionStreams[from];
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private viewer(senderKey: string, at: number): ViewerLedger {
|
|
752
|
+
let viewer = this.ledger.viewers[senderKey];
|
|
753
|
+
if (!viewer) {
|
|
754
|
+
viewer = {
|
|
755
|
+
lastSeenAt: at,
|
|
756
|
+
superchats: [],
|
|
757
|
+
interactionStreams: {},
|
|
758
|
+
fairDebt: 0,
|
|
759
|
+
};
|
|
760
|
+
this.ledger.viewers[senderKey] = viewer;
|
|
761
|
+
}
|
|
762
|
+
return viewer;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private maintain(at: number): void {
|
|
766
|
+
if (at - this.lastMaintenanceAt < 60 * MINUTE_MS) return;
|
|
767
|
+
this.lastMaintenanceAt = at;
|
|
768
|
+
const interactionCutoff = at - this.tuning.interactionWindowMs;
|
|
769
|
+
const scCutoff = at - this.tuning.superchatWindowMs;
|
|
770
|
+
const inactiveCutoff = at - Math.max(
|
|
771
|
+
this.tuning.guardRetentionMs,
|
|
772
|
+
this.tuning.interactionWindowMs,
|
|
773
|
+
this.tuning.superchatWindowMs,
|
|
774
|
+
);
|
|
775
|
+
let changed = false;
|
|
776
|
+
for (const [senderKey, viewer] of Object.entries(this.ledger.viewers)) {
|
|
777
|
+
const scLength = viewer.superchats.length;
|
|
778
|
+
viewer.superchats = viewer.superchats.filter((entry) => entry.at >= scCutoff);
|
|
779
|
+
if (viewer.superchats.length !== scLength) changed = true;
|
|
780
|
+
for (const [streamId, stream] of Object.entries(viewer.interactionStreams)) {
|
|
781
|
+
const relevant = stream.qualifiedAt !== undefined
|
|
782
|
+
? stream.qualifiedAt >= interactionCutoff
|
|
783
|
+
: stream.lastAt >= interactionCutoff;
|
|
784
|
+
if (!relevant) {
|
|
785
|
+
delete viewer.interactionStreams[streamId];
|
|
786
|
+
changed = true;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const active = (viewer.guardExpiresAt ?? 0) > at ||
|
|
790
|
+
(viewer.superchatExpiresAt ?? 0) > at ||
|
|
791
|
+
(viewer.interactionExpiresAt ?? 0) > at;
|
|
792
|
+
if (
|
|
793
|
+
!active &&
|
|
794
|
+
viewer.lastSeenAt < inactiveCutoff &&
|
|
795
|
+
viewer.superchats.length === 0 &&
|
|
796
|
+
Object.keys(viewer.interactionStreams).length === 0
|
|
797
|
+
) {
|
|
798
|
+
delete this.ledger.viewers[senderKey];
|
|
799
|
+
changed = true;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (changed) this.markDirty();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private markDirty(): void {
|
|
806
|
+
this.dirty = true;
|
|
807
|
+
if (!this.file || this.saveTimer) return;
|
|
808
|
+
this.saveTimer = setTimeout(() => {
|
|
809
|
+
this.saveTimer = null;
|
|
810
|
+
try {
|
|
811
|
+
this.flush();
|
|
812
|
+
} catch (error) {
|
|
813
|
+
this.onPersistError?.(error instanceof Error ? error : new Error(String(error)));
|
|
814
|
+
}
|
|
815
|
+
}, this.tuning.persistIntervalMs);
|
|
816
|
+
this.saveTimer.unref?.();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private load(): AudienceLedgerFile | null {
|
|
820
|
+
if (!this.file) return null;
|
|
821
|
+
const backup = `${this.file}.bak`;
|
|
822
|
+
if (!existsSync(this.file) && existsSync(backup)) renameSync(backup, this.file);
|
|
823
|
+
if (!existsSync(this.file)) return null;
|
|
824
|
+
try {
|
|
825
|
+
return parseLedger(JSON.parse(readFileSync(this.file, 'utf8')));
|
|
826
|
+
} catch (error) {
|
|
827
|
+
throw new Error(`invalid audience ledger ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private freshLedger(): AudienceLedgerFile {
|
|
832
|
+
return {
|
|
833
|
+
schemaVersion: LEDGER_VERSION,
|
|
834
|
+
salt: this.createId(),
|
|
835
|
+
openStream: null,
|
|
836
|
+
viewers: {},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function validateTuning(tuning: AudienceAdmissionTuning): AudienceAdmissionTuning {
|
|
842
|
+
const positive = [
|
|
843
|
+
'superchatSingleYuan',
|
|
844
|
+
'superchatRollingYuan',
|
|
845
|
+
'superchatWindowMs',
|
|
846
|
+
'superchatLeaseMs',
|
|
847
|
+
'guardRetentionMs',
|
|
848
|
+
'interactionWindowMs',
|
|
849
|
+
'interactionLeaseMs',
|
|
850
|
+
'interactionMinutesPerStream',
|
|
851
|
+
'interactionStreamCount',
|
|
852
|
+
'onlineRankCrowdedOn',
|
|
853
|
+
'onlineRankReleaseMs',
|
|
854
|
+
'onlineRankFreshMs',
|
|
855
|
+
'onlineRankStaleHoldMs',
|
|
856
|
+
'lineBudget',
|
|
857
|
+
'tokenBudget',
|
|
858
|
+
'persistIntervalMs',
|
|
859
|
+
] as const;
|
|
860
|
+
for (const key of positive) {
|
|
861
|
+
if (!Number.isFinite(tuning[key]) || tuning[key] <= 0) throw new Error(`${key} must be positive`);
|
|
862
|
+
}
|
|
863
|
+
if (!Number.isFinite(tuning.onlineRankCrowdedOff) || tuning.onlineRankCrowdedOff < 0) {
|
|
864
|
+
throw new Error('onlineRankCrowdedOff must be non-negative');
|
|
865
|
+
}
|
|
866
|
+
if (tuning.onlineRankCrowdedOff >= tuning.onlineRankCrowdedOn) {
|
|
867
|
+
throw new Error('online rank release threshold must be below activation threshold');
|
|
868
|
+
}
|
|
869
|
+
if (tuning.onlineRankStaleHoldMs < tuning.onlineRankFreshMs) {
|
|
870
|
+
throw new Error('online rank stale hold must cover the fresh interval');
|
|
871
|
+
}
|
|
872
|
+
if (tuning.importantBudgetShare <= 0 || tuning.importantBudgetShare > 1) {
|
|
873
|
+
throw new Error('importantBudgetShare must be in (0, 1]');
|
|
874
|
+
}
|
|
875
|
+
return tuning;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function normalizedStart(value: number | string | null | undefined): string | null {
|
|
879
|
+
if (value === null || value === undefined || value === '') return null;
|
|
880
|
+
if (typeof value === 'number') {
|
|
881
|
+
if (!Number.isFinite(value)) throw new Error('liveStartedAt must be finite');
|
|
882
|
+
return String(value);
|
|
883
|
+
}
|
|
884
|
+
const normalized = value.trim();
|
|
885
|
+
return normalized || null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function uidOf(value: string): string | null {
|
|
889
|
+
const normalized = value.trim();
|
|
890
|
+
return /^[1-9]\d*$/.test(normalized) ? normalized : null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function uniqueUids(values: readonly string[]): string[] {
|
|
894
|
+
return [...new Set(values.map(uidOf).filter((value): value is string => value !== null))].sort();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function timestampOf(value: number): number {
|
|
898
|
+
if (!Number.isFinite(value)) throw new Error('timestamp must be finite');
|
|
899
|
+
return Math.floor(value);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function lineCount(text: string): number {
|
|
903
|
+
return text.split(/\r?\n/).length;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function maxDebt<TCandidate extends AudienceAdmissionCandidate>(
|
|
907
|
+
item: ClassifiedCandidate<TCandidate>,
|
|
908
|
+
viewers: Record<string, ViewerLedger>,
|
|
909
|
+
): number {
|
|
910
|
+
return Math.max(...item.importantParticipants.map((participant) => viewers[participant.senderKey]!.fairDebt));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseLedger(raw: unknown): AudienceLedgerFile {
|
|
914
|
+
if (!isRecord(raw) || raw.schemaVersion !== LEDGER_VERSION || typeof raw.salt !== 'string' || !raw.salt) {
|
|
915
|
+
throw new Error('invalid audience ledger');
|
|
916
|
+
}
|
|
917
|
+
const openStream = parseOpenStream(raw.openStream);
|
|
918
|
+
if (!isRecord(raw.viewers)) throw new Error('invalid audience viewers');
|
|
919
|
+
const viewers: Record<string, ViewerLedger> = {};
|
|
920
|
+
for (const [senderKey, value] of Object.entries(raw.viewers)) {
|
|
921
|
+
if (!uidOf(senderKey) || !isRecord(value)) continue;
|
|
922
|
+
const viewer = parseViewer(value);
|
|
923
|
+
if (viewer) viewers[senderKey] = viewer;
|
|
924
|
+
}
|
|
925
|
+
return { schemaVersion: LEDGER_VERSION, salt: raw.salt, openStream, viewers };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function parseOpenStream(raw: unknown): OpenStream | null {
|
|
929
|
+
if (raw === null || raw === undefined) return null;
|
|
930
|
+
if (!isRecord(raw) || !Number.isInteger(raw.roomId) || typeof raw.id !== 'string' || !raw.id) {
|
|
931
|
+
throw new Error('invalid open stream');
|
|
932
|
+
}
|
|
933
|
+
return { roomId: raw.roomId as number, id: raw.id };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function parseViewer(raw: Record<string, unknown>): ViewerLedger | null {
|
|
937
|
+
if (!finiteNumber(raw.lastSeenAt)) return null;
|
|
938
|
+
const superchats = Array.isArray(raw.superchats)
|
|
939
|
+
? raw.superchats.flatMap((entry) => {
|
|
940
|
+
if (!isRecord(entry) || !finiteNumber(entry.at) || !finiteNumber(entry.cents) || entry.cents < 0) return [];
|
|
941
|
+
return [{ at: entry.at, cents: entry.cents }];
|
|
942
|
+
})
|
|
943
|
+
: [];
|
|
944
|
+
const interactionStreams: Record<string, InteractionStream> = {};
|
|
945
|
+
if (isRecord(raw.interactionStreams)) {
|
|
946
|
+
for (const [streamId, value] of Object.entries(raw.interactionStreams)) {
|
|
947
|
+
if (!isRecord(value) || !finiteNumber(value.lastAt) || !Array.isArray(value.minutes)) continue;
|
|
948
|
+
const minutes = value.minutes.filter((minute): minute is number => Number.isInteger(minute));
|
|
949
|
+
interactionStreams[streamId] = {
|
|
950
|
+
lastAt: value.lastAt,
|
|
951
|
+
minutes,
|
|
952
|
+
...(finiteNumber(value.qualifiedAt) ? { qualifiedAt: value.qualifiedAt } : {}),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
lastSeenAt: raw.lastSeenAt,
|
|
958
|
+
...(finiteNumber(raw.guardExpiresAt) ? { guardExpiresAt: raw.guardExpiresAt } : {}),
|
|
959
|
+
...(finiteNumber(raw.superchatExpiresAt) ? { superchatExpiresAt: raw.superchatExpiresAt } : {}),
|
|
960
|
+
superchats,
|
|
961
|
+
...(finiteNumber(raw.interactionExpiresAt) ? { interactionExpiresAt: raw.interactionExpiresAt } : {}),
|
|
962
|
+
interactionStreams,
|
|
963
|
+
fairDebt: finiteNumber(raw.fairDebt) ? raw.fairDebt : 0,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function persistLedger(file: string, ledger: AudienceLedgerFile): void {
|
|
968
|
+
const dir = dirname(file);
|
|
969
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
970
|
+
const suffix = `${process.pid}-${randomUUID()}`;
|
|
971
|
+
const temporary = `${file}.tmp-${suffix}`;
|
|
972
|
+
const backup = `${file}.bak`;
|
|
973
|
+
writeFileSync(temporary, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
|
|
974
|
+
if (!existsSync(file)) {
|
|
975
|
+
renameSync(temporary, file);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (existsSync(backup)) rmSync(backup);
|
|
979
|
+
renameSync(file, backup);
|
|
980
|
+
try {
|
|
981
|
+
renameSync(temporary, file);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
renameSync(backup, file);
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
rmSync(backup);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
990
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function finiteNumber(value: unknown): value is number {
|
|
994
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
995
|
+
}
|