mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
659 lines
23 KiB
TypeScript
659 lines
23 KiB
TypeScript
/**
|
|
* The weekly challenge rotation, generated from the calendar week rather than authored.
|
|
*
|
|
* A week's rotation is a pure function of which week it is: the same five challenges, the
|
|
* same window and the same gift for every player, recomputed identically by every isolate
|
|
* and every request. That is not a nicety — `challenge_status` rows are scoped by
|
|
* `ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
|
|
* callers who disagreed about what this week holds would disagree about who has finished it.
|
|
* Everything here therefore hangs off {@link rotationIndex} and a seeded PRNG; nothing calls
|
|
* `Math.random()` or reads the clock except to work out which week it is.
|
|
*
|
|
* The client evaluates the rule trees and the server never does (see
|
|
* .agents/skills/weekly-challenge-config/SKILL.md), so a generated tree is a specification
|
|
* handed to a client that fails SILENTLY when it's malformed. Everything emitted here is
|
|
* therefore built from the three idioms that are pinned by captured live data or by a
|
|
* rotation this server has already served — no lib-only node types, no invented fields.
|
|
*
|
|
* `static/weekly-challenge.json` still ships, and still wins: a non-empty `Challenges` array
|
|
* there PINS the week to that hand-authored rotation and skips generation entirely, which is
|
|
* how a debug or event rotation gets served without a code change. When it is empty the file
|
|
* supplies only the parts generation doesn't own — the fallback gift name, the theme string,
|
|
* and `CompletedRequired`.
|
|
*/
|
|
|
|
import weeklyChallenge from '../static/weekly-challenge.json'
|
|
|
|
/** Rec Room's weekly reset: Wednesday 21:00 UTC, the boundary both captured rotations sit on. */
|
|
const ROTATION_EPOCH_MS = Date.UTC(2020, 0, 1, 21, 0, 0)
|
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* Where generated `ChallengeMapId`s start. Hand-authored rotations have used small ids (the
|
|
* captured 17, this repo's 19), and a generated id that collided with one would let a
|
|
* player's stored rows from that rotation read as progress against a completely different
|
|
* set of challenges. A four-digit floor keeps the two id spaces from ever meeting.
|
|
*/
|
|
const CHALLENGE_MAP_ID_BASE = 1000
|
|
|
|
/** How many challenges a week publishes. Five is the captured rotation's size, and what the three-of-five gift threshold is written against. */
|
|
const CHALLENGES_PER_ROTATION = 5
|
|
|
|
/**
|
|
* How many challenges of one kind a week may hold. Five slots over three kinds with a cap of
|
|
* two guarantees every kind appears, so no week is five variations of "finish some games".
|
|
*/
|
|
const MAX_PER_KIND = 2
|
|
|
|
/** `ct:6` event ids — `ChallengeEventTypes`. Only the two the captured/served trees use. */
|
|
const EVENT_GAME_END = 2
|
|
const EVENT_ELIMINATED_AI = 5
|
|
|
|
/** The targets each kind counts to. Fixed rather than rolled: a week should vary in WHAT it asks, not in how much. */
|
|
const GAMES_TARGET = 5
|
|
const AI_TARGET = 10
|
|
|
|
/** What a challenge asks for. Each maps to one proven `Config` idiom and one line of copy. */
|
|
type ChallengeKind = 'games' | 'win' | 'ai'
|
|
|
|
/**
|
|
* A room the generator may name, keyed by the scene(s) its games run in.
|
|
*
|
|
* `scenes` is what `ct:7` matches, and one scene can belong to several rooms (Soccer and
|
|
* Stadium are one scene; Dodgeball, Gym and DodgeballVR are another) — `shares` records the
|
|
* rooms a challenge naming this one also completes in, which is a property of the game data,
|
|
* not something the tree can narrow. Entries are one per scene, so picking by room key also
|
|
* keeps a week from naming one scene twice.
|
|
*
|
|
* `link` is the `^Token` the client resolves into a tappable room name; `null` where the room
|
|
* name would make a doubtful token (Charades spans two scenes and starts with a digit) and
|
|
* the copy falls back to plain text, which the captured rotation also does.
|
|
*/
|
|
interface ChallengeRoom {
|
|
/** Stable key — also the slug fragment in a generated `Name`. */
|
|
key: string
|
|
/** Plain display name, used when `link` is null. */
|
|
name: string
|
|
/** The `^Token` room link, or null to write the name plainly. */
|
|
link: string | null
|
|
/** `UnitySceneId`s this room's games run in, straight from apps/rooms/static/ImportRooms.json. */
|
|
scenes: string[]
|
|
/** The kinds this room can be asked for — quests have enemies to defeat, hangouts have no games at all. */
|
|
kinds: ChallengeKind[]
|
|
/** True for the quest rooms, whose "win" is completing the quest rather than beating other players. */
|
|
quest?: boolean
|
|
/** Other rooms on the same scene, which a challenge naming this one also completes in. */
|
|
shares?: string
|
|
}
|
|
|
|
/**
|
|
* The rooms in play. Every scene id here resolves in `apps/rooms/static/ImportRooms.json` —
|
|
* a challenge naming a scene this server hosts no room for can never be completed by anyone,
|
|
* and nothing server-side would report that.
|
|
*
|
|
* `kinds` is deliberately conservative. `win` reads the `won` session variable, which is
|
|
* pinned by the captured rotation for quests and is meaningful in a head-to-head game, so
|
|
* rooms where "winning" is vague (bowling, disc golf, charades, Stunt Runner) only ever ask
|
|
* for completed games. `ai` is quests only: they are the rooms with enemies in them.
|
|
*/
|
|
const CHALLENGE_ROOMS: ChallengeRoom[] = [
|
|
// Quests — win the quest, or thin out its enemies.
|
|
{
|
|
key: 'GoldenTrophy',
|
|
name: 'Quest for the Golden Trophy',
|
|
link: '^GoldenTrophy',
|
|
scenes: ['91e16e35-f48f-4700-ab8a-a1b79e50e51b'],
|
|
kinds: ['win', 'ai'],
|
|
quest: true,
|
|
},
|
|
{
|
|
key: 'Jumbotron',
|
|
name: 'The Rise of Jumbotron',
|
|
link: '^TheRiseofJumbotron',
|
|
scenes: ['acc06e66-c2d0-4361-b0cd-46246a4c455c'],
|
|
kinds: ['win', 'ai'],
|
|
quest: true,
|
|
},
|
|
{
|
|
key: 'CrimsonCauldron',
|
|
name: 'Curse of the Crimson Cauldron',
|
|
link: '^CrimsonCauldron',
|
|
scenes: ['949fa41f-4347-45c0-b7ac-489129174045'],
|
|
kinds: ['win', 'ai'],
|
|
quest: true,
|
|
},
|
|
{
|
|
key: 'IsleOfLostSkulls',
|
|
name: 'The Isle of Lost Skulls',
|
|
link: '^IsleOfLostSkulls',
|
|
scenes: ['7e01cfe0-820a-406f-b1b3-0a5bf575235c'],
|
|
kinds: ['win', 'ai'],
|
|
quest: true,
|
|
},
|
|
{
|
|
key: 'Crescendo',
|
|
name: 'Crescendo of the Blood Moon',
|
|
link: '^Crescendo',
|
|
scenes: ['49cb8993-a956-43e2-86f4-1318f279b22a'],
|
|
kinds: ['win', 'ai'],
|
|
quest: true,
|
|
},
|
|
|
|
// Head-to-head rooms — finish games, or win one.
|
|
{
|
|
key: 'Clearcut',
|
|
name: 'Paintball: Clear Cut',
|
|
link: '^Paintball.Clearcut',
|
|
scenes: ['380d18b5-de9c-49f3-80f7-f4a95c1de161'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'PaintballVR/Clearcut, Clearcut/Home',
|
|
},
|
|
{
|
|
key: 'River',
|
|
name: 'Paintball: River',
|
|
link: '^Paintball.River',
|
|
scenes: ['e122fe98-e7db-49e8-a1b1-105424b6e1f0'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'PaintballVR/River, River/Home',
|
|
},
|
|
{
|
|
key: 'Homestead',
|
|
name: 'Paintball: Homestead',
|
|
link: '^Paintball.Homestead',
|
|
scenes: ['a785267d-c579-42ea-be43-fec1992d1ca7'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'PaintballVR/Homestead, Homestead/Home',
|
|
},
|
|
{
|
|
key: 'Quarry',
|
|
name: 'Paintball: Quarry',
|
|
link: '^Paintball.Quarry',
|
|
scenes: ['ff4c6427-7079-4f59-b22a-69b089420827'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'PaintballVR/Quarry, Quarry/Home',
|
|
},
|
|
{
|
|
key: 'Spillway',
|
|
name: 'Paintball: Spillway',
|
|
link: '^Paintball.Spillway',
|
|
scenes: ['58763055-2dfb-4814-80b8-16fac5c85709'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'PaintballVR/Spillway, Spillway/Home',
|
|
},
|
|
{
|
|
key: 'Dodgeball',
|
|
name: 'Dodgeball',
|
|
link: '^Dodgeball',
|
|
scenes: ['3d474b26-26f7-45e9-9a36-9b02847d5e6f'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'Gym/Home, DodgeballVR/Home',
|
|
},
|
|
{
|
|
key: 'Soccer',
|
|
name: 'Soccer',
|
|
link: '^Soccer',
|
|
scenes: ['6d5eea4b-f069-4ed0-9916-0e2f07df0d03'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'Stadium/Home',
|
|
},
|
|
{
|
|
key: 'Hangar',
|
|
name: 'Laser Tag: Hangar',
|
|
link: '^LaserTag.Hangar',
|
|
scenes: ['239e676c-f12f-489f-bf3a-d4c383d692c3'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'Hangar/Home',
|
|
},
|
|
{
|
|
key: 'CyberJunkCity',
|
|
name: 'Laser Tag: CyberJunk City',
|
|
link: '^LaserTag.CyberJunkCity',
|
|
scenes: ['9d6456ce-6264-48b4-808d-2d96b3d91038'],
|
|
kinds: ['games', 'win'],
|
|
shares: 'LaserTagCyberJunk/Home, CyberJunkCity/Home',
|
|
},
|
|
{
|
|
key: 'Paddleball',
|
|
name: 'Paddleball',
|
|
link: '^Paddleball',
|
|
scenes: ['d89f74fa-d51e-477a-a425-025a891dd499'],
|
|
kinds: ['games', 'win'],
|
|
},
|
|
{
|
|
key: 'FrontierSolos',
|
|
name: 'Rec Royale: Solos',
|
|
link: '^RecRoyaleSolos',
|
|
scenes: ['b010171f-4875-4e89-baba-61e878cd41e1'],
|
|
kinds: ['games', 'win'],
|
|
},
|
|
{
|
|
key: 'FrontierSquads',
|
|
name: 'Rec Royale: Squads',
|
|
link: '^RecRoyaleSquads',
|
|
scenes: ['253fa009-6e65-4c90-91a1-7137a56a267f'],
|
|
kinds: ['games', 'win'],
|
|
},
|
|
|
|
// Rooms where finishing is the whole ask — "winning" one of these isn't a thing the
|
|
// `won` variable is known to report.
|
|
{
|
|
key: 'Bowling',
|
|
name: 'Bowling',
|
|
link: '^Bowling',
|
|
scenes: ['ae929543-9a07-41d5-8ee9-dbbee8c36800'],
|
|
kinds: ['games'],
|
|
shares: 'BowlingAlley/Home',
|
|
},
|
|
{
|
|
key: 'DiscGolfLake',
|
|
name: 'Disc Golf: Lake',
|
|
link: '^DiscGolfLake',
|
|
scenes: ['f6f7256c-e438-4299-b99e-d20bef8cf7e0'],
|
|
kinds: ['games'],
|
|
shares: 'Lake/Home',
|
|
},
|
|
{
|
|
key: 'DiscGolfPropulsion',
|
|
name: 'Disc Golf: Propulsion',
|
|
link: '^DiscGolfPropulsion',
|
|
scenes: ['d9378c9f-80bc-46fb-ad1e-1bed8a674f55'],
|
|
kinds: ['games'],
|
|
shares: 'PropulsionTestRange/Home',
|
|
},
|
|
{
|
|
key: 'Charades',
|
|
name: 'Charades',
|
|
link: null,
|
|
// Both charades scenes, as the captured rotation's own charades challenge does.
|
|
scenes: ['a673712c-877f-4749-b69a-4a4c6310d545', '4078dfed-24bb-4db7-863f-578ba48d726b'],
|
|
kinds: ['games'],
|
|
shares: '3DCharades/InkSpaceHome, Legacy3DCharades/Home',
|
|
},
|
|
{
|
|
key: 'StuntRunner',
|
|
name: 'Stunt Runner',
|
|
link: '^StuntRunner',
|
|
scenes: ['b7281665-a715-4051-826b-8e08e69c6172'],
|
|
kinds: ['games'],
|
|
},
|
|
]
|
|
|
|
/** One challenge as the rotation serves it — `Complete` is stamped per caller by `getCurrent`. */
|
|
export interface RotationChallenge {
|
|
ChallengeId: number
|
|
Name: string
|
|
Config: string
|
|
Description: string
|
|
Tooltip: string
|
|
Complete: boolean
|
|
}
|
|
|
|
/**
|
|
* The rotation's reward block. Same item vocabulary as a storefront gift drop, but with
|
|
* `Context`/`Rarity` spelled `GiftContext`/`GiftRarity` — the two shapes are not
|
|
* interchangeable, see `toChallengeGiftDrop` in econ.app.ts.
|
|
*/
|
|
export interface ChallengeGiftBlock {
|
|
GiftDropId: number
|
|
AvatarItemDesc: string
|
|
AvatarItemType: number
|
|
ConsumableItemDesc: string
|
|
EquipmentPrefabName: string
|
|
EquipmentModificationGuid: string
|
|
StorefrontType: number
|
|
Xp: number
|
|
Level: number
|
|
GiftContext: number
|
|
GiftRarity: number
|
|
/**
|
|
* Display strings, OPTIONAL because neither the captured rotation nor a generated block
|
|
* carries them — the reward's name is resolved from the catalog entry selling the same
|
|
* item, falling back to `FallbackGiftName`. A pinned rotation can set them to name its
|
|
* reward outright.
|
|
*/
|
|
FriendlyName?: string
|
|
Tooltip?: string
|
|
}
|
|
|
|
/** A week's whole rotation — the body `GET /api/challenge/v2/getCurrent` serves. */
|
|
export interface WeeklyChallengeRotation {
|
|
ChallengeMapId: number
|
|
CompletedRequired: boolean
|
|
StartAt: string
|
|
EndAt: string
|
|
ServerTime: string
|
|
Challenges: RotationChallenge[]
|
|
Gift: ChallengeGiftBlock
|
|
FallbackGiftName: string
|
|
/**
|
|
* What the week is themed on — the FriendlyName of the item its `Gift` hands over, set
|
|
* by {@link withWeeklyGift} once the catalog has named the roll. The static file's value
|
|
* is only a placeholder: a generated week's reward isn't known until it is rolled. A
|
|
* PINNED rotation keeps whatever string it ships.
|
|
*/
|
|
ChallengeThemeString: string
|
|
}
|
|
|
|
/** An equipment item the weekly gift can be drawn from — one sf3 entry, trimmed to what a gift needs. */
|
|
export interface EquipmentGift {
|
|
GiftDropId: number
|
|
EquipmentPrefabName: string
|
|
EquipmentModificationGuid: string
|
|
Rarity: number
|
|
/** The catalog's display name for the item — what the week is themed on. */
|
|
FriendlyName: string
|
|
}
|
|
|
|
/** Whether the shipped file pins the week, in which case nothing here is generated. */
|
|
function pinnedRotation(): WeeklyChallengeRotation | null {
|
|
return weeklyChallenge.Challenges.length > 0 ? (weeklyChallenge as WeeklyChallengeRotation) : null
|
|
}
|
|
|
|
/**
|
|
* Which week it is: whole weeks since the epoch, so the value ticks over at Wednesday 21:00
|
|
* UTC and every caller in the same week gets the same number.
|
|
*/
|
|
export function rotationIndex(now: Date): number {
|
|
return Math.floor((now.getTime() - ROTATION_EPOCH_MS) / WEEK_MS)
|
|
}
|
|
|
|
/**
|
|
* This week's `ChallengeMapId` — the identity of the rotation, and what makes a stored
|
|
* completion belong to one week rather than another. Cheap on purpose: `updateProgress` asks
|
|
* only this to decide whether a report is against the live week.
|
|
*/
|
|
export function rotationMapId(now: Date): number {
|
|
return pinnedRotation()?.ChallengeMapId ?? CHALLENGE_MAP_ID_BASE + rotationIndex(now)
|
|
}
|
|
|
|
/** The week's window, as the client's `StartAt`/`EndAt` want it: UTC, but written without a zone. */
|
|
function rotationWindow(index: number): { StartAt: string; EndAt: string } {
|
|
const start = ROTATION_EPOCH_MS + index * WEEK_MS
|
|
return {
|
|
StartAt: toLocalIsoString(new Date(start)),
|
|
EndAt: toLocalIsoString(new Date(start + WEEK_MS)),
|
|
}
|
|
}
|
|
|
|
/** `2026-08-19T21:00:00` — ISO 8601 with the milliseconds and the `Z` cut off, which is the shape the client's window fields take. */
|
|
function toLocalIsoString(at: Date): string {
|
|
return at.toISOString().slice(0, 19)
|
|
}
|
|
|
|
/**
|
|
* `2026-08-25T14:42:54.2754728Z` — .NET's round-trip format, seven fractional digits. The
|
|
* client dates its countdown off this, and because a generated window is genuinely the
|
|
* current one, this is the real clock rather than the frozen timestamp a static file needs.
|
|
*/
|
|
function toDotNetString(at: Date): string {
|
|
return `${at.toISOString().slice(0, -1)}0000Z`
|
|
}
|
|
|
|
/** mulberry32 — a small deterministic PRNG. Same seed, same week, same rotation, everywhere. */
|
|
function mulberry32(seed: number): () => number {
|
|
let a = seed >>> 0
|
|
return () => {
|
|
a = (a + 0x6d2b79f5) >>> 0
|
|
let t = a
|
|
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spread a week index into a seed. Adjacent weeks are adjacent integers, and feeding those
|
|
* straight in makes consecutive rotations correlate; a multiply by a large odd constant
|
|
* (Knuth's) scatters them.
|
|
*/
|
|
function seedFor(mapId: number, salt: number): number {
|
|
return Math.imul(mapId ^ salt, 2654435761) >>> 0
|
|
}
|
|
|
|
/** In-place Fisher-Yates against a seeded stream — the only place ordering comes from. */
|
|
function shuffle<T>(items: T[], random: () => number): T[] {
|
|
const out = [...items]
|
|
for (let i = out.length - 1; i > 0; i--) {
|
|
const j = Math.floor(random() * (i + 1))
|
|
const a = out[i] as T
|
|
const b = out[j] as T
|
|
out[i] = b
|
|
out[j] = a
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** A `ct:7` scene allow-list node. */
|
|
function sceneNode(scenes: string[]) {
|
|
return { ct: 7, vs: scenes.map((l) => ({ l })) }
|
|
}
|
|
|
|
/**
|
|
* The rule tree for one (kind, room), as an object — stringified into `Config` by the caller.
|
|
* Each branch is one of the three idioms in the skill doc, with the field order the captured
|
|
* trees use:
|
|
*
|
|
* - `games` — a counter over finished games in the room (`ct:1` + `GameEnd` + scene).
|
|
* - `win` — one finished game in the room that the player won (`ct:0` + `GameEnd` + `won` + scene).
|
|
* - `ai` — a counter over enemies defeated in the room (`ct:1` + `EliminatedAI` + scene).
|
|
*/
|
|
function configFor(kind: ChallengeKind, room: ChallengeRoom): unknown {
|
|
const scene = sceneNode(room.scenes)
|
|
switch (kind) {
|
|
case 'games':
|
|
return {
|
|
ct: 1,
|
|
ipc: false,
|
|
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_GAME_END] }, scene] }],
|
|
t: GAMES_TARGET,
|
|
}
|
|
case 'win':
|
|
return {
|
|
ct: 0,
|
|
ipc: false,
|
|
wc: [{ ct: 6, vs: [EVENT_GAME_END] }, { ct: 9, vs: [true], v: 'won' }, scene],
|
|
}
|
|
case 'ai':
|
|
return {
|
|
ct: 1,
|
|
ipc: false,
|
|
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_ELIMINATED_AI] }, scene] }],
|
|
t: AI_TARGET,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The copy for one (kind, room). Generated from the same two inputs as the tree, which is
|
|
* the point: the client renders these strings and evaluates the tree independently, so
|
|
* hand-written copy is free to drift into describing a challenge that doesn't exist.
|
|
*/
|
|
function copyFor(
|
|
kind: ChallengeKind,
|
|
room: ChallengeRoom
|
|
): { Description: string; Tooltip: string } {
|
|
const where = room.link ?? room.name
|
|
switch (kind) {
|
|
case 'games':
|
|
return {
|
|
Description: `Complete ${GAMES_TARGET} games in ${where}`,
|
|
Tooltip: `Play ${GAMES_TARGET} games of ${room.name} through to the end. Winning is optional.`,
|
|
}
|
|
case 'win':
|
|
return room.quest === true
|
|
? {
|
|
Description: `Complete the ${where} quest`,
|
|
Tooltip: `See ${room.name} through to a win.`,
|
|
}
|
|
: {
|
|
Description: `Win a game in ${where}`,
|
|
Tooltip: `Come out on top of a game of ${room.name}.`,
|
|
}
|
|
case 'ai':
|
|
return {
|
|
Description: `Defeat ${AI_TARGET} enemies in ${where}`,
|
|
Tooltip: `Take out ${AI_TARGET} enemies in ${room.name}. They don't have to be in one run.`,
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The internal slug — never displayed, but it's what a log line or a D1 row is read against. */
|
|
function nameFor(kind: ChallengeKind, room: ChallengeRoom): string {
|
|
switch (kind) {
|
|
case 'games':
|
|
return `Complete${GAMES_TARGET}Games${room.key}`
|
|
case 'win':
|
|
return `Win${room.key}`
|
|
case 'ai':
|
|
return `Defeat${AI_TARGET}AI${room.key}`
|
|
}
|
|
}
|
|
|
|
/** One thing the generator may publish: a room crossed with a kind that room supports. */
|
|
interface Candidate {
|
|
challengeId: number
|
|
kind: ChallengeKind
|
|
room: ChallengeRoom
|
|
}
|
|
|
|
/**
|
|
* Every (room, kind) pair, in a fixed order — the index in this list IS the challenge id.
|
|
*
|
|
* Deriving the id from the pair rather than from the position in a week keeps ids meaningful
|
|
* across weeks: id 12 is always "win in Dodgeball", so a `challenge_status` row that outlives
|
|
* its rotation is at worst stale, never a different challenge wearing the same id. Ids are
|
|
* only required to be unique within a rotation, which distinct pairs trivially are.
|
|
*
|
|
* Appending to `CHALLENGE_ROOMS` is safe; INSERTING into the middle renumbers everything
|
|
* after it, so add rooms at the end.
|
|
*/
|
|
const CANDIDATES: Candidate[] = CHALLENGE_ROOMS.flatMap((room) =>
|
|
room.kinds.map((kind) => ({ challengeId: 0, kind, room }))
|
|
).map((candidate, index) => ({ ...candidate, challengeId: index + 1 }))
|
|
|
|
/**
|
|
* Pick the week's challenges: shuffle every candidate, then take the first five that keep
|
|
* one room out of two slots and one kind out of three. The relaxation pass exists so the
|
|
* constraints can never under-deliver a rotation — a short week would quietly lower the gift
|
|
* threshold, since it clamps to what's published.
|
|
*/
|
|
function pickChallenges(random: () => number): RotationChallenge[] {
|
|
const shuffled = shuffle(CANDIDATES, random)
|
|
const picked: Candidate[] = []
|
|
const rooms = new Set<string>()
|
|
const kinds = new Map<ChallengeKind, number>()
|
|
for (const pass of [0, 1]) {
|
|
for (const candidate of shuffled) {
|
|
if (picked.length === CHALLENGES_PER_ROTATION) break
|
|
if (rooms.has(candidate.room.key)) continue
|
|
if (pass === 0 && (kinds.get(candidate.kind) ?? 0) >= MAX_PER_KIND) continue
|
|
picked.push(candidate)
|
|
rooms.add(candidate.room.key)
|
|
kinds.set(candidate.kind, (kinds.get(candidate.kind) ?? 0) + 1)
|
|
}
|
|
}
|
|
return picked.map(({ challengeId, kind, room }) => ({
|
|
ChallengeId: challengeId,
|
|
Name: nameFor(kind, room),
|
|
Config: JSON.stringify(configFor(kind, room)),
|
|
...copyFor(kind, room),
|
|
Complete: false,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* The week's gift: one equipment item, drawn from the pool with the week's own seed.
|
|
*
|
|
* Weekly rewards are equipment — the captured rotation's is a camera skin — so the pool is
|
|
* every sf3 item carrying an `EquipmentModificationGuid`. Drawing from the live catalog
|
|
* rather than a copied list is what lets `toChallengeGiftDrop` resolve the pick back to the
|
|
* entry selling it and hand the player a properly named item.
|
|
*
|
|
* Null when the pool is empty (the catalog didn't load), and the caller keeps the static
|
|
* file's block so the reward preview is still something rather than nothing.
|
|
*/
|
|
function pickWeeklyGift(
|
|
mapId: number,
|
|
pool: EquipmentGift[]
|
|
): { gift: ChallengeGiftBlock; friendlyName: string } | null {
|
|
if (pool.length === 0) return null
|
|
const random = mulberry32(seedFor(mapId, 0x9e3779b9))
|
|
const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift
|
|
// The name comes back alongside rather than on the block: the block is the wire shape,
|
|
// whose display strings are optional and left unset here so `toChallengeGiftDrop` keeps
|
|
// resolving them from the catalog entry that sells the item.
|
|
return {
|
|
friendlyName: gift.FriendlyName,
|
|
gift: {
|
|
GiftDropId: gift.GiftDropId,
|
|
AvatarItemDesc: '',
|
|
AvatarItemType: 0,
|
|
ConsumableItemDesc: '',
|
|
EquipmentPrefabName: gift.EquipmentPrefabName,
|
|
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
|
StorefrontType: 0,
|
|
Xp: 0,
|
|
Level: 0,
|
|
GiftContext: 0,
|
|
GiftRarity: gift.Rarity,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Memoised generation. The rotation is identical for every caller in a week, so it is built
|
|
* once per isolate per week rather than per request; `ServerTime` is the one field that
|
|
* moves, and it is written fresh on the way out.
|
|
*
|
|
* The cached object is never handed out directly for that reason, and callers that
|
|
* personalise it (`getCurrent` stamping per-player state) rebuild rather than mutate.
|
|
*/
|
|
let cached: WeeklyChallengeRotation | null = null
|
|
|
|
/**
|
|
* This week's rotation, with the static file's `Gift` as a placeholder — callers that can
|
|
* read the catalog replace it with {@link pickWeeklyGift}. Pure apart from `now`: same week
|
|
* in, same rotation out.
|
|
*/
|
|
export function buildRotation(now: Date): WeeklyChallengeRotation {
|
|
const pinned = pinnedRotation()
|
|
if (pinned !== null) return pinned
|
|
const index = rotationIndex(now)
|
|
const mapId = CHALLENGE_MAP_ID_BASE + index
|
|
if (cached === null || cached.ChallengeMapId !== mapId) {
|
|
cached = {
|
|
ChallengeMapId: mapId,
|
|
CompletedRequired: weeklyChallenge.CompletedRequired,
|
|
...rotationWindow(index),
|
|
ServerTime: '',
|
|
Challenges: pickChallenges(mulberry32(seedFor(mapId, 0))),
|
|
Gift: weeklyChallenge.Gift as ChallengeGiftBlock,
|
|
FallbackGiftName: weeklyChallenge.FallbackGiftName,
|
|
ChallengeThemeString: weeklyChallenge.ChallengeThemeString,
|
|
}
|
|
}
|
|
return { ...cached, ServerTime: toDotNetString(now) }
|
|
}
|
|
|
|
/**
|
|
* Put the week's own reward on a rotation. Separate from {@link buildRotation} because the
|
|
* pool comes from the storefront catalog, which is an async read the cheap paths
|
|
* (`updateProgress` deciding whether a report is against the live week) have no reason to pay.
|
|
*
|
|
* A PINNED rotation is returned untouched: it ships its own `Gift`, and overwriting that with
|
|
* a rolled one would make the pin a half-pin.
|
|
*/
|
|
export function withWeeklyGift(
|
|
rotation: WeeklyChallengeRotation,
|
|
pool: EquipmentGift[]
|
|
): WeeklyChallengeRotation {
|
|
if (pinnedRotation() !== null) return rotation
|
|
const picked = pickWeeklyGift(rotation.ChallengeMapId, pool)
|
|
if (picked === null) return rotation
|
|
// The week is themed on its reward: `ChallengeThemeString` is the item's catalog name,
|
|
// which is the same string `toChallengeGiftDrop` resolves for the grant, so the heading
|
|
// and the thing handed over read as one. The static file's value is a placeholder — it
|
|
// can't name an item that is rolled per week.
|
|
return { ...rotation, Gift: picked.gift, ChallengeThemeString: picked.friendlyName }
|
|
}
|