Rejoin: same exact instance before, with time ago

This commit is contained in:
Claude committed 2026-09-27 02:38:26 +02:00
1 parent 1afc9bad95
commit ad608daf8b
7 files changed
+76 -148

No files matched your search

+13 -31
View File
@@ -16,7 +16,7 @@
import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
import type { InstanceFilter } from './filters.js';
import { summarise, toMeetEvent, toTimelineEntries, type MeetEvent, type MeetHistory } from './history.js';
import { rejoinIn, toTimelineEntries, UNKNOWN_REJOIN, type Rejoin } from './history.js';
import type { Settings } from './settings.js';
import {
profileLoaded,
@@ -47,7 +47,7 @@ export interface Facts {
readonly avatar: AvatarFacts | undefined;
/** `undefined` when no group filter is configured. */
readonly inGroup: boolean | undefined;
readonly history: MeetHistory;
readonly rejoin: Rejoin;
}
type Ctx = PluginContext<Settings>;
@@ -95,8 +95,6 @@ export class FactCollector {
#latest: CurrentInstance | undefined;
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */
readonly #perfCache = new Map<string, AvatarFacts>();
/** The latest first_meet / meet_again VRCNext pushed per user; it can arrive before the join line. */
readonly #meets = new Map<string, MeetEvent>();
constructor(ctx: Ctx) {
this.#ctx = ctx;
@@ -104,10 +102,6 @@ export class FactCollector {
const instance = toCurrentInstance(payload);
if (instance !== undefined) this.#latest = instance;
});
ctx.events.on('timelineEvent', (payload) => {
const meet = toMeetEvent(payload);
if (meet !== undefined) this.#meets.set(meet.userId, meet);
});
ctx.events.on('vrcAvatarDetail', (payload) => {
const perf = toAvatarPerformance(payload);
if (perf !== undefined) {
@@ -124,11 +118,11 @@ export class FactCollector {
async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise<Facts> {
const signal = this.#ctx.signal;
const joinedAt = Date.now();
const [profile, inGroup, avatar, history] = await Promise.all([
const [profile, inGroup, avatar, rejoin] = await Promise.all([
this.#profile(joiner, deadlineMs, signal),
filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal),
this.#avatar(joiner, deadlineMs, signal),
this.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }),
this.#rejoin(joiner, joinedAt, { ms: deadlineMs, signal }),
]);
return {
ageVerified: profile === undefined ? undefined : profile.ageVerified,
@@ -136,37 +130,25 @@ export class FactCollector {
platform: profile?.platform ?? '',
avatar,
inGroup,
history,
rejoin,
};
}
/**
* VRCNext's own record of earlier meetings: the live meet event for this join, plus the user's
* recent timeline. Legacy accounts without a `usr_` id have neither.
* Whether VRCNext's timeline already has this player in this exact instance. Legacy accounts
* without a `usr_` id cannot be looked up.
*/
async #history(
joiner: Joiner,
filter: InstanceFilter,
joinedAt: number,
window: { readonly ms: number; readonly signal: AbortSignal },
): Promise<MeetHistory> {
const { ms, signal } = window;
if (joiner.userId === '') return summarise(undefined, undefined, filter, joinedAt);
const meetPending = this.#meets.get(joiner.userId) !== undefined
? Promise.resolve(this.#meets.get(joiner.userId))
: waitFor(this.#ctx.events, 'timelineEvent', (payload) => {
const meet = toMeetEvent(payload);
return meet?.userId === joiner.userId ? meet : undefined;
}, { ms: Math.min(ms, 8_000), signal });
const timelinePending = waitFor(
async #rejoin(joiner: Joiner, joinedAt: number, window: { readonly ms: number; readonly signal: AbortSignal }): Promise<Rejoin> {
const location = this.#latest?.location ?? '';
if (joiner.userId === '' || location === '') return UNKNOWN_REJOIN;
const pending = waitFor(
this.#ctx.events,
'timelineForUser',
(payload) => toTimelineEntries(payload, joiner.userId),
{ ms, signal },
window,
);
this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId });
const [meet, entries] = await Promise.all([meetPending, timelinePending]);
return summarise(meet, entries, filter, joinedAt);
return rejoinIn(await pending, location, joinedAt);
}
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
+46 -80
View File
@@ -1,51 +1,25 @@
/**
* Whether a joiner has been met before, from VRCNext's own timeline.
* Whether a joiner has been in this exact instance with you before, from VRCNext's own timeline.
*
* VRCNext keeps a `known_users` table and, on every join, records a `first_meet` or `meet_again`
* timeline event for that player (pushed live as `timelineEvent`, with `meetCount` on repeats).
* `getTimelineForUser` returns the ten most recent timeline events involving the player, each
* with a timestamp and the location it happened in. Both survive VRCNext and VRChat restarts and
* reach back to when VRCNext was installed, which no memory of the plugin's own could.
*
* The ten-event window is the honest limit: "how many times in a matching instance" counts only
* what is in it, and the report says so.
* `getTimelineForUser` returns the ten most recent timeline events involving the player, each with
* a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they survive
* VRCNext and VRChat restarts and reach back to when VRCNext was installed. The check is strict on
* purpose: same world and same instance id, not "met somewhere" and not "an instance like this".
*/
import { instanceMatches, shapeOfLocation, type InstanceFilter } from './filters.js';
export interface MeetEvent {
readonly type: 'first_meet' | 'meet_again';
readonly userId: string;
readonly timestamp: string;
readonly meetCount: number;
}
export interface TimelineEntry {
readonly type: string;
readonly timestamp: string;
readonly worldName: string;
readonly location: string;
}
export interface MeetHistory {
/** `undefined` when VRCNext said nothing in time. */
readonly metBefore: boolean | undefined;
/** VRCNext's count of earlier meetings, when it reported one. */
readonly meetCount: number;
readonly lastMet: { readonly at: string; readonly worldName: string } | undefined;
/** Earlier events, within the window VRCNext returned, that happened in a matching instance. */
readonly matchingBefore: number | undefined;
/** How many earlier events the window held, so the report can say how far back it looked. */
readonly windowSize: number;
export interface Rejoin {
/** `undefined` when VRCNext did not answer in time. */
readonly seenHere: boolean | undefined;
/** When the earlier visit was recorded, as VRCNext's timestamp. */
readonly lastAt: string | undefined;
}
export const UNKNOWN_HISTORY: MeetHistory = {
metBefore: undefined,
meetCount: 0,
lastMet: undefined,
matchingBefore: undefined,
windowSize: 0,
};
export const UNKNOWN_REJOIN: Rejoin = { seenHere: undefined, lastAt: undefined };
function rec(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
@@ -55,17 +29,6 @@ function str(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Narrows a `timelineEvent` push to the two meeting kinds; anything else is `undefined`. */
export function toMeetEvent(payload: unknown): MeetEvent | undefined {
const r = rec(payload);
if (r === undefined) return undefined;
const type = str(r['type']);
const userId = str(r['userId']);
if ((type !== 'first_meet' && type !== 'meet_again') || userId === '') return undefined;
const count = r['meetCount'];
return { type, userId, timestamp: str(r['timestamp']), meetCount: typeof count === 'number' ? count : 0 };
}
/** Narrows a `timelineForUser` reply for one user. */
export function toTimelineEntries(payload: unknown, userId: string): readonly TimelineEntry[] | undefined {
const r = rec(payload);
@@ -73,44 +36,47 @@ export function toTimelineEntries(payload: unknown, userId: string): readonly Ti
const events = Array.isArray(r['events']) ? r['events'] : [];
return events.flatMap((e) => {
const entry = rec(e);
if (entry === undefined) return [];
return [{
type: str(entry['type']),
timestamp: str(entry['timestamp']),
worldName: str(entry['worldName']),
location: str(entry['location']),
}];
return entry === undefined ? [] : [{ timestamp: str(entry['timestamp']), location: str(entry['location']) }];
});
}
/** `wrld_…:12345` — the part of a location that identifies the instance, without its modifiers. */
export function instanceKey(location: string): string {
return location.split('~')[0] ?? '';
}
/**
* Combines the live meet event with the user's timeline into one answer.
*
* Events from the current join are excluded by timestamp: anything at or after `joinedAt`
* describes this visit, not an earlier one.
* Looks for an earlier event in the same instance. Events from this join are excluded by
* timestamp: anything at or after `joinedAt` describes this visit, not an earlier one.
*/
export function summarise(
meet: MeetEvent | undefined,
export function rejoinIn(
entries: readonly TimelineEntry[] | undefined,
filter: InstanceFilter,
location: string,
joinedAt: number,
): MeetHistory {
const earlier = (entries ?? []).filter((e) => {
const at = Date.parse(e.timestamp);
return Number.isFinite(at) && at < joinedAt - 5_000;
});
earlier.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const metBefore = meet !== undefined ? meet.type === 'meet_again' : (entries === undefined ? undefined : earlier.length > 0);
): Rejoin {
if (entries === undefined) return UNKNOWN_REJOIN;
const key = instanceKey(location);
if (key === '') return { seenHere: false, lastAt: undefined };
const earlier = entries
.filter((e) => instanceKey(e.location) === key)
.filter((e) => {
const at = Date.parse(e.timestamp);
return Number.isFinite(at) && at < joinedAt - 5_000;
})
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const last = earlier[0];
const matching = entries === undefined
? undefined
: earlier.filter((e) => e.location !== '' && instanceMatches(filter, shapeOfLocation(e.location))).length;
return {
metBefore,
meetCount: meet?.meetCount ?? 0,
lastMet: last === undefined ? undefined : { at: last.timestamp, worldName: last.worldName },
matchingBefore: matching,
windowSize: earlier.length,
};
return { seenHere: last !== undefined, lastAt: last?.timestamp };
}
/** `3 minutes ago`, `2 hours ago`, `5 days ago`. */
export function timeAgo(iso: string, now = Date.now()): string {
const ms = now - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return 'just now';
const minutes = Math.floor(ms / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${String(hours)} hour${hours === 1 ? '' : 's'} ago`;
const days = Math.floor(hours / 24);
return `${String(days)} day${days === 1 ? '' : 's'} ago`;
}
+8 -23
View File
@@ -13,7 +13,7 @@
import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api';
import type { Facts, Joiner } from './collector.js';
import type { MeetHistory } from './history.js';
import { timeAgo, type Rejoin } from './history.js';
import type { Settings } from './settings.js';
import type { CurrentInstance } from './vrcnext-data.js';
@@ -24,8 +24,6 @@ export interface Report {
readonly facts: Facts;
/** Whether a group filter was active, so the "In Group" line is only shown when it means something. */
readonly groupFilter: string;
/** Whether any filter was active, so the matching-instance count is only shown when it means something. */
readonly filtered: boolean;
}
type Ctx = PluginContext<Settings>;
@@ -42,24 +40,11 @@ function rank(value: string): string {
return value === '' ? 'Unknown' : value;
}
/**
* `Yes (met 3× before, last 26/09/2026, 21:40 in Club X; 2 of the last 3 in matching instances)`.
* Counts come from VRCNext: `meetCount` from its meet_again event, the rest from the ten most
* recent timeline events it returned, which is why the matching count says how many events it looked at.
*/
function rejoinText(history: MeetHistory, filtered: boolean): string {
if (history.metBefore === undefined) return 'Unknown';
if (!history.metBefore) return 'No (first time)';
const bits: string[] = [];
if (history.meetCount > 0) bits.push(`met ${String(history.meetCount)}× before`);
if (history.lastMet !== undefined) {
const when = new Date(history.lastMet.at).toLocaleString();
bits.push(`last ${when}${history.lastMet.worldName === '' ? '' : ` in ${history.lastMet.worldName}`}`);
}
if (filtered && history.matchingBefore !== undefined && history.windowSize > 0) {
bits.push(`${String(history.matchingBefore)} of the last ${String(history.windowSize)} in matching instances`);
}
return bits.length === 0 ? 'Yes' : `Yes (${bits.join('; ')})`;
/** `Yes (2 hours ago)`: this player was in this exact instance with you before, per VRCNext's timeline. */
function rejoinText(rejoin: Rejoin): string {
if (rejoin.seenHere === undefined) return 'Unknown';
if (!rejoin.seenHere) return 'No';
return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`;
}
/** The report as the spec lays it out, one fact per line. */
@@ -75,7 +60,7 @@ export function reportLines(report: Report): readonly string[] {
`Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`,
];
if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`);
lines.push(`Rejoin?: ${rejoinText(report.facts.history, report.filtered)}`);
lines.push(`Rejoin?: ${rejoinText(report.facts.rejoin)}`);
return lines;
}
@@ -88,7 +73,7 @@ export function reportSummary(report: Report): string {
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
];
if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`);
bits.push(report.facts.history.metBefore === undefined ? 'history ?' : (report.facts.history.metBefore ? 'rejoin' : 'new'));
bits.push(report.facts.rejoin.seenHere === undefined ? 'rejoin ?' : (report.facts.rejoin.seenHere ? 'rejoin' : 'new'));
return `${report.joiner.name} joined · ${bits.join(' · ')}`;
}
+2 -2
View File
@@ -71,7 +71,7 @@ export class ReportPanel {
icon: 'build',
children: [
k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'),
k.description('Rejoin history comes from VRCNext’s own timeline, so there is nothing to clear here.'),
k.description('Rejoin is answered from VRCNext’s own timeline: was this player in this exact instance with you before.'),
k.buttonRow(
k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
),
@@ -114,7 +114,7 @@ export class ReportPanel {
return k.row({
label: `${time} · ${report.joiner.name}`,
detail: lines.slice(1).join(' · '),
value: report.facts.history.metBefore === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.history.metBefore === false ? 'New' : 'History ?'),
value: report.facts.rejoin.seenHere === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.rejoin.seenHere === false ? 'New' : 'Rejoin ?'),
});
});
}