Rejoin: same exact instance before, with time ago
This commit is contained in:
1 parent
1afc9bad95
commit
ad608daf8b
7 files changed
+76
-148
No files matched your search
+13
-31
@@ -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
@@ -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
@@ -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
@@ -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 ?'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user