feat(guild): wake_mapping, per-recipient wakeup, discuss/work turn engine, channel join/leave
- wake_mapping table; triage onDuty (auto-added member) / custom listeners - per-recipient wakeup metadata on message.created (one message-id; added only at push). Rules: author=false; triage/custom=wake_mapping only; general=all; report=none - discuss/work rotation: channel_turn_state (order/currentSpeaker/round events/cross-round no-reply streak); null activation, queue-jump, /no-reply pass, all-/no-reply pause, end-of-round shuffle (trailing no-reply run to tail, head shuffled, first != last normal speaker) - slash-command registry (/no-reply, /force-proceed); registered commands intercepted and never delivered; guild-authored /ack persisted - POST /channels/:id/join|leave; leave cleans channel_members, wake_mapping and turn-state order Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
src/channels/turn-shuffle.ts
Normal file
52
src/channels/turn-shuffle.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RoundEvent } from '../entities/channel-turn-state.entity';
|
||||
|
||||
export type ShuffleResult = { paused: true } | { paused: false; newOrder: string[] };
|
||||
|
||||
function shuffleInPlace<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// End-of-round shuffle.
|
||||
// - tail = the *last contiguous run* of /no-reply in the round's turn events
|
||||
// (anchor = first of that run; kept in event order)
|
||||
// - head = every current member not in tail, shuffled randomly
|
||||
// - constraint: head[0] !== D, where D = the last member who delivered a
|
||||
// normal message in the round
|
||||
// - if the constraint is unsatisfiable (head empty, or head === [D]) the
|
||||
// rotation pauses instead of shuffling (per spec B.3)
|
||||
export function computeShuffle(roundEvents: RoundEvent[], currentMembers: string[]): ShuffleResult {
|
||||
const memberSet = new Set(currentMembers);
|
||||
|
||||
// trailing contiguous /no-reply run, in event order, limited to current members
|
||||
const tail: string[] = [];
|
||||
for (let i = roundEvents.length - 1; i >= 0; i--) {
|
||||
if (roundEvents[i].a !== 'noreply') break;
|
||||
if (memberSet.has(roundEvents[i].u)) tail.unshift(roundEvents[i].u);
|
||||
}
|
||||
const tailSet = new Set(tail);
|
||||
|
||||
// D = last normal speaker in the round (the one right before the trailing run)
|
||||
let d: string | null = null;
|
||||
for (let i = roundEvents.length - 1; i >= 0; i--) {
|
||||
if (roundEvents[i].a === 'normal' && memberSet.has(roundEvents[i].u)) {
|
||||
d = roundEvents[i].u;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const head = currentMembers.filter((u) => !tailSet.has(u));
|
||||
if (head.length === 0) return { paused: true };
|
||||
if (head.length === 1 && head[0] === d) return { paused: true };
|
||||
|
||||
shuffleInPlace(head);
|
||||
if (head[0] === d) {
|
||||
// length >= 2 here (the [d] singleton case returned paused above)
|
||||
[head[0], head[1]] = [head[1], head[0]];
|
||||
}
|
||||
|
||||
return { paused: false, newOrder: [...head, ...tail] };
|
||||
}
|
||||
Reference in New Issue
Block a user