/** * World boss rings (James, 2026-10-09; design `.cursor/task-plans/2026-10-09-world-boss-events-design.md`). A ring * is an open-air stone floor circled by pillars where, on a fixed server-wide schedule, a boss turns up for everyone * nearby to fight together. The first is Ringstone, the Old Ring of Hopewood Common grown outward, between No Harm Farm * and Hope Town (tier 1, levels 1-10). Rings are data, so later regions get their own with the same engine. * * Phase 1 (this file and backend src/worldBossRing.ts) is the place and the countdown: the floor, the pillars, the * sign, and six countdown stages, each change told once to everyone online in world chat. The fight is phase 2. * * Everything here is wall-clock maths: event n starts at n * RING_PERIOD_MS (ms since the Unix epoch), so a restart, * a deploy or a second server agrees on the stage and the boss without saving anything. */ /** One countdown stage, 1 (hours away) to 6 (prepare, then the fight). */ export type RingStage = 1 | 2 | 3 | 4 | 5 | 6; export declare const RING_STAGES: readonly RingStage[]; /** The bosses a ring may roll (NpcKinds). */ export type RingBossKind = 'brownbear' | 'giantcrab' | 'boarlord' | 'alphamosswolf' | 'bogtoadking' | 'greatheron' | 'brackenboarking' | 'hedgeogre' | 'cryptknight' | 'rotwoodent' | 'bloodmireelemental' | 'spidermatriarch'; export type WorldBossRing = { id: string; /** What players read: the sign, the chat lines. */ title: string; /** Where the chat line says it is. */ where: string; /** The empty centre of the floor, where the boss will stand. */ centre: { x: number; z: number; }; /** Tiles within this (Euclidean, tile centres) are the stone floor. */ floorRadius: number; /** The pillars stand on this circle; within it counts as "inside the ring". */ pillarRadius: number; pillarCount: number; /** The model each pillar is drawn with (a dreadmodel id: Ringstone's own Blender pillar, quest-workshop ringstonepillar_v001.py). */ pillarModel: string; /** Kept clear of everything: the boss and the crowd fight here. */ clearRadius: number; tier: { id: number; minLevel: number; maxLevel: number; }; bossPool: readonly RingBossKind[]; /** The board at the entrance, beside the worn path up from the road. */ sign: { x: number; z: number; }; /** Where the path leaves the floor (the gap in the pillars). */ entrance: { x: number; z: number; }; /** The caged chest's place at the floor's edge, away from the entrance. Reserved: its prop is phase 2. */ cage: { x: number; z: number; }; /** * Added to the wall clock before the 3-hour slots are cut: event n of this ring starts at n * RING_PERIOD_MS + eventOffsetMs. * Ring 2 is offset by half a period so two rings never fight at once (T25, 2026-10-14). 0 for Ringstone. */ eventOffsetMs: number; /** What the floor, the satchels and the failed fight give. Placeholders until the balance pass (rule: balance last). */ rewards: RingRewards; /** * The ground dressing of the ring: 'common' (Ringstone's meadow), 'hill' (rocks, thorn and rough fence), 'marsh' (reeds, shallow pools and * lily pads), 'meadow' (bracken clumps, loose stones and a rough fence on open grass) or 'lair' (a Dreadwoods lair awakening: no pillars, sign or lamps, the lair's own ground; see `lair`). */ dressing: 'common' | 'hill' | 'marsh' | 'meadow' | 'lair'; /** Time from one event's start to the next. Omitted: RING_PERIOD_MS (3 hours). A lair is awakened once in a rota of lairs, so its own period is longer. */ periodMs?: number; /** The countdown's chat lines start at this stage (omitted: stage 1, every stage). A lair stays quiet until the last hour. */ announceFrom?: RingStage; /** Set on a Dreadwoods lair awakening (T82): the extra rules of that fight. */ lair?: LairRules; }; /** * The extra rules of a lair awakening (lairAwakening.ts). PvP stays on and nobody can heal inside the arena; the loot goes by rank * of damage dealt, not by who lands the last blow or who stands where. */ export type LairRules = { /** The lair's zone (DreadwoodsNpcZoneId) and the creature of its pack that a Bounty contract counts the awakened boss as. */ zone: string; bountyTarget: string | null; /** The pack creature kills a qualifying fighter is credited on an active Bounty when the boss falls. */ bountyCredit: number; /** A satchel or ladder reward needs this share of all the damage dealt to the boss (stops an alt padding its name on with one blow). */ minShare: number; /** The thrown gear is reserved, piece by piece, for the top damage dealers, this long, then it is anyone's. */ floorReserveMs: number; /** The ladder: rank 1..n each get these on top of their satchel (placeholders). */ ladder: ReadonlyArray<{ rank: number; items: ReadonlyArray<{ kind: string; min: number; max: number; }>; }>; }; /** One ring's rewards (all item kinds are real items; numbers are placeholders). */ export type RingRewards = { /** The thrown gear: the first half from `floorA` (the odd one too), the rest from `floorB`. */ floorA: readonly string[]; floorB: readonly string[]; satchelCoins: { min: number; max: number; }; satchelLoot: ReadonlyArray<{ kind: string; min: number; max: number; }>; consolationCoins: { min: number; max: number; }; }; /** The ids of a ring's pieces: Ringstone's own are `ringstone-sign`, `ringstone-chest`... so every ring is `-...`. */ export declare const ringSignId: (ring: WorldBossRing) => string; export declare const ringChestId: (ring: WorldBossRing) => string; export declare const ringCageId: (ring: WorldBossRing) => string; export declare const ringPillarId: (ring: WorldBossRing, i: number) => string; export declare const ringLampIds: (ring: WorldBossRing) => readonly [string, string]; /** The world-timer keys a ring saves under (Ringstone's are the original 'ringstone' and 'ringstone-fight'). */ export declare const ringTimerKey: (ring: WorldBossRing) => string; export declare const ringFightTimerKey: (ring: WorldBossRing) => string; /** * Ringstone. Centre surveyed 2026-10-09 against Hopewood Common (backend hopewoodCommon.ts): the Common Strips' hedge * is at z 884 (their claim to 886), the Orchard Road's rows are z 921-923 (kept clear to 920), the Bracken Brook's * water stays west of x 147 at these rows, Hayrick Yard starts at x 204. So the pillar circle (z 887-917, x 151-181) * fits between the strips and the road with the sign and the path in the three rows left. */ export declare const RINGSTONE: WorldBossRing; /** * Highstone, the second world boss ring (T25, 2026-10-14; D5 / E6, tier 2, levels 20-30; sheets `ringstone-ring2-1..6`): * the pillars and the caged chest on the open hill east of Highwayman's Ridge, where the ground is rock and thorn. Surveyed * 2026-10-14: grass with no props within 22 tiles of (870, 432), 5-6 m of gentle relief. The path leaves the floor to the south * (the Fishery road side), the board stands beside it. The pillars are the same Blender pillar as Ringstone's (the sheet's * rocky pillars are a later pass). */ export declare const HIGHSTONE: WorldBossRing; /** * Fenstone, the third world boss ring (T40, 2026-10-14; D5 / E6, tier 3, levels 30-40; sheet `world-boss-ring-more-bosses`: the Bog * Toad King's "shallow water, reeds and lily pads" and the Great Heron's "tall reeds, feathers and nests"): the pillars and the caged * chest on the open grass flats south of the Capital's river and north of the Trout Fishery. Surveyed 2026-10-14 (backend dump-region): * clean grass, no props for 65 rows, 6 m of gentle relief. The path leaves the floor to the south, the board stands beside it. The pillars * are the same Blender pillar as the other rings' (the sheet's mossy pillars are a later pass). Its clock is a third of a period after * Ringstone's and a third before Highstone's (offsets 0, 60 and 90 minutes: the fight windows are 10 minutes, so none ever overlap). */ export declare const FENSTONE: WorldBossRing; /** * Brackenstone, the fourth world boss ring (T106, 2026-10-14; coverage slice 6, tier 2, levels 11-20): the level 11-20 band had no crowd * boss. The pillars and the caged chest on the open bracken meadow just east of the Capital's wall, between the Capital and Fenstone. * Surveyed 2026-10-14 (backend dump-region): grass only, 3 m of relief under the floor, only the world's own trees and rocks, which the * ring builder clears. The path leaves the floor to the south, the board stands beside it. The pillars are the same Blender pillar as * the other rings' (stand-in). * * THE CLOCK. The three-hour grid is full (Ringstone 0, Fenstone 60, Highstone 90, the lairs and the Heartwood Hunt at 130, the Fishing * Derby's rounds at 40-60 mod 120, the Redcap patrol at 00:20), so Brackenstone runs on a SIX-hour period offset 210 minutes: its fight * window opens at 03:30, 09:30, 15:30 and 21:30 UTC, at least 20 minutes from every other ring and 30 from the Derby (brackenstone-test.mjs * checks 300 slots of every clock). Four events a day instead of eight is the price; the balance pass can revisit it. */ export declare const BRACKENSTONE: WorldBossRing; export declare const WORLD_BOSS_RINGS: readonly WorldBossRing[]; /** The three stone rings and the four lair awakenings (lairAwakening.ts, T82): everything that runs the ring engine on a clock. */ export declare const ALL_BOSS_RINGS: readonly WorldBossRing[]; /** The entity id of Ringstone's board: the client shows the live stage over it when clicked or when you are near. */ export declare const RINGSTONE_SIGN_ID = "ringstone-sign"; /** How near (Chebyshev tiles) a player stands to the board for it to show the stage by itself. */ export declare const RING_SIGN_READ_RANGE = 4; export declare function worldBossRing(id: string): WorldBossRing | undefined; /** Time from one event's start to the next (one constant: 3 hours, 8 a day, while the game has few players). */ export declare const RING_PERIOD_MS: number; /** How long a boss has to be killed once it appears (phase 2). The ring stays on stage 6 for this long. */ export declare const RING_FIGHT_MS: number; /** * The stages by time to the next event, longest first: a stage holds while the time left is MORE than its `over` * (stage 6 holds from 2 minutes out, through the start and the fight window). */ export declare const RING_STAGE_TABLE: ReadonlyArray<{ stage: RingStage; over: number; text: string; colour: string; }>; export declare function isRingStage(n: unknown): n is RingStage; /** The slot of the most recent event start at `now`: event n starts at n * RING_PERIOD_MS + the ring's offset. */ export declare function slotAt(now: number, ring?: WorldBossRing): number; /** The time from one of this ring's events to the next (RING_PERIOD_MS unless the ring has its own). */ export declare function ringPeriodMs(ring: WorldBossRing): number; /** When event `slot` of this ring starts (ms since the epoch). */ export declare function slotStart(slot: number, ring?: WorldBossRing): number; /** When the next event starts (strictly after `now`). */ export declare function nextEventAt(now: number, ring?: WorldBossRing): number; /** The countdown stage for this much time left to the next event. */ export declare function stageAt(msToNext: number): RingStage; export declare function stageText(stage: RingStage): string; /** The stage's lamp colour (CSS hex), for the sign and the pillar lamps. */ export declare function stageColour(stage: RingStage): string; /** * "1:42:05", "42:05", "0:07": the time to a ring's next boss as a clock (James, 2026-10-20: "a clear countdown"), seconds * rounded up, never below 0:00. Hours appear only when there are any. */ export declare function formatRingCountdown(ms: number): string; /** "2 hours 50 minutes", "1 hour", "30 minutes", "1 minute": the time to a ring's next boss in words, minutes rounded up. */ export declare function ringEtaWords(ms: number): string; /** The ring's next boss: when it comes (strictly after `now`), how long until then, and which boss it is. */ export declare function ringNextBoss(now: number, ring?: WorldBossRing): { at: number; msToStart: number; boss: RingBossKind; }; /** * The world chat line for a stage. Given the time left and the boss (the backend always gives both), it says plainly who comes, * where and when: "World event: Grizzlemaw the Brown Bear comes to Ringstone, between the farm and the town, in 30 minutes." * Without them it falls back to the stage's words. */ export declare function chatLine(stage: RingStage, ring?: WorldBossRing, msToNext?: number, boss?: RingBossKind): string; /** * Where the clock stands at `now`: the event it is counting towards (or fighting) and the stage. For RING_FIGHT_MS * after an event starts the ring stays on stage 6 for that event (the fight, phase 2); then it counts to the next. */ export declare function ringClockAt(now: number, ring?: WorldBossRing): { eventSlot: number; stage: RingStage; msToNext: number; }; /** * The boss for an event slot, the same on every server and after every restart, and never the boss of the slot * before. With two in the pool they alternate (the phase of the alternation is hashed); with more, even slots roll * freely and each odd slot rolls among the bosses that are neither of its even neighbours'. */ export declare function bossForSlot(slot: number, ring?: WorldBossRing): RingBossKind; /** Tile on the ring's stone floor. */ export declare function inRingFloor(x: number, z: number, ring?: WorldBossRing): boolean; /** Tile inside the pillar circle: standing here counts as being in the ring. */ export declare function inRing(x: number, z: number, ring?: WorldBossRing): boolean; /** * The pillars' tiles, clockwise from the east, offset half a step so the south (the entrance) falls in a gap * between two of them. */ export declare function ringPillars(ring?: WorldBossRing): Array<{ x: number; z: number; }>; /** * The Old Ring's nine standing stones, moved out from the middle to a loose ring just inside the pillars (James, * 2026-10-09: the centre stays empty for the fight). Angles in degrees, clockwise from the east (z grows south), kept * off the entrance (south) and the cage (north); radii vary a little so it reads as old and loose, not laid out. */ export declare const RING_OLD_STONE_ANGLES: ReadonlyArray<{ deg: number; r: number; }>; export declare function ringOldStones(ring?: WorldBossRing): Array<{ x: number; z: number; }>; /** One ring boss's numbers. */ export type RingBoss = { kind: RingBossKind; /** What it is: "Brown Bear". With no `ownName` it is also what players call it (a lair boss: "Crypt Knight"). */ name: string; /** * Its own name (James, 2026-10-20: "the bear being called bear is a bit low quality, it should have a name"): what its bar, * its nameplate and every line call it ("Grizzlemaw"), introduced with what it is ("Grizzlemaw the Brown Bear"). Every * stone ring's boss has one; the lair bosses keep the name of their lair's boss. */ ownName?: string; level: number; /** H1: its health against one attacker. The crowd multiplies it by n^RING_SCALE_EXPONENT. */ baseHealth: number; /** The hold's blow: its name (bossSignatures.ts id) and the raw roll before armour. It always lands. */ signature: 'maul' | 'pincer' | 'tusk-toss' | 'jaw-clamp' | 'tongue-grab' | 'beak-grab' | 'gore-pin' | 'ogre-clutch' | 'grave-cleave' | 'root-slam' | 'mire-slam' | 'brood-bite'; signatureName: string; holdHit: { min: number; max: number; }; /** * How far its body reaches (tiles): it strikes and is struck from here. Matches the drawn body (frontend creatureModels.ts * CREATURE_AUTHORED_SCALE): the ring bosses were drawn bigger on 2026-10-20 (James: "it should ... be larger"), so a player * standing at this distance meets the snout, not the middle of the animal. */ reach: number; /** The word for the grab: "The !". */ grabs: string; /** A lair boss: the line a seized player reads (its periodic signature's notice is about its ordinary blow). Omitted: the signature's own. */ grabNotice?: string; /** * Tier 2 and up: besides the hold, a telegraphed move at the whole crowd. Every `everyMs` the boss tells it (a full * BOSS_SIGNATURE_TELL_MS ahead, combat_windup with `signature`); everyone inside the pillars and not held, within `radius` * tiles of its body when it lands, takes `hit` through their armour. Step beyond the radius before it lands and it is dodged * (the E8 counter: "You step clear of the Boarlord's Tusk Rush."). Ring 1's bosses have none. */ sweep?: { signature: 'tusk-rush' | 'pack-howl' | 'mire-slam' | 'wing-gale' | 'bramble-stampede' | 'club-sweep' | 'tomb-hammer' | 'stomp' | 'blood-surge' | 'web-spit'; name: string; everyMs: number; radius: number; hit: { min: number; max: number; }; tellLine: string; }; }; /** * The tier 1 bosses. Health is tuned by the backend's scripts/ringstone-fight-sim.ts against the real combat maths: * one player cannot do it in ten minutes, three just about fail, four or five with food take seven to nine minutes, a * crowd a few. The hold's 3-5 always lands: four of them kill a level 1-3 player without food (11-13 health with the * Lower Kingdoms' blessing) and a level 4-10 one who eats once lives. */ export declare const RING_BOSSES: Readonly>; /** What its bar, its nameplate and the board call a ring boss: its own name ("Grizzlemaw"), or a lair boss's ("Crypt Knight"). */ export declare function ringBossName(kind: RingBossKind): string; /** Its name and what it is, for a list or a ledger: "Grizzlemaw the Brown Bear", or "Crypt Knight". */ export declare function ringBossTitle(kind: RingBossKind): string; /** * A ring boss in a sentence: "Grizzlemaw" or "the Crypt Knight" (`start`: "The Crypt Knight"; `full`: "Grizzlemaw the Brown * Bear", for the first time a line names it). */ export declare function ringBossRef(kind: RingBossKind, opts?: { start?: boolean; full?: boolean; }): string; export declare function isRingBossKind(kind: string): kind is RingBossKind; /** A held player is struck once a round, this many times, then let go. */ export declare const RING_HOLD_ROUNDS = 4; /** A round of the hold: the game's combat round (two 1200 ms turns). */ export declare const RING_HOLD_ROUND_MS = 2400; /** One hold at a time per this many attackers (at least one): up to 7 attackers one, 8-11 two, 12-15 three. */ export declare const RING_ATTACKERS_PER_HOLD = 4; /** An attacker is a player who hurt the boss within this long. */ export declare const RING_ATTACKER_WINDOW_MS = 60000; /** The crowd's count is smoothed over about this long, so one player stepping out is no jump. */ export declare const RING_ATTACKER_SMOOTH_MS = 20000; export declare const RING_SCALE_EXPONENT = 0.3; /** The boss turns up only this soon after the event starts (a late tick, never a boot long after it). */ export declare const RING_SPAWN_GRACE_MS = 30000; /** After the kill the chest waits this long to be pried open, then bursts by itself. */ export declare const RING_CHEST_WAIT_MS = 120000; /** The thrown gear lies on the floor this long, free for anyone. */ export declare const RING_FLOOR_ITEM_MS = 90000; /** At most this many pieces are thrown. */ export declare const RING_FLOOR_ITEM_CAP = 15; /** A satchel needs this long inside the pillars during the fight, or a blow on the boss. */ export declare const RING_SATCHEL_MIN_INSIDE_MS = 60000; /** The tier 1 floor gear: bronze, and plain wood (half each; the bronze gets the odd one). Ring 1's rewards (RINGSTONE.rewards). */ export declare const RING_FLOOR_BRONZE: readonly string[]; export declare const RING_FLOOR_WOOD: readonly string[]; /** A satchel: coins and one or two small things (ring 1's; every ring has its own in `rewards`). */ export declare const RING_SATCHEL_COINS: { min: number; max: number; }; export declare const RING_SATCHEL_LOOT: ReadonlyArray<{ kind: string; min: number; max: number; }>; /** The failed fight's consolation for anyone who hurt it: coins only (ring 1's). */ export declare const RING_CONSOLATION_COINS: { min: number; max: number; }; /** The boss's health against `attackers` (smoothed, so not a whole number): H1 * n^0.3, never below H1. */ export declare function ringBossMaxHealth(kind: RingBossKind, attackers: number): number; /** How many players it holds at once against this many attackers (at least one). */ export declare function ringHoldCount(attackers: number): number; /** The pieces the chest throws for `players` inside at the kill: half from floorA, half from floorB (the odd one from A), capped. */ export declare function ringFloorDrops(players: number, random?: () => number, ring?: WorldBossRing): string[]; /** One satchel: coins plus one or two different small things. */ export declare function ringSatchel(random?: () => number, ring?: WorldBossRing): Array<{ kind: string; qty: number; }>; /** The failed fight's satchel: coins only. */ export declare function ringConsolation(random?: () => number, ring?: WorldBossRing): Array<{ kind: string; qty: number; }>; export type RingFightPhase = 'calm' | 'fight' | 'won' | 'failed'; export type RingChestState = 'locked' | 'ready' | 'open'; /** What a client is told about the fight (message 'ringstone_fight'). */ export type RingFightView = { ringId: string; phase: RingFightPhase; boss: RingBossKind | null; /** The boss NPC's id while it stands. */ bossId: string | null; /** Time left in the fight window when this was sent (0 outside a fight). */ msLeft: number; health: number; healthMax: number; /** The players held right now. */ held: string[]; /** The cage at the north edge is down (the chest locked in it) until the kill. */ caged: boolean; chest: RingChestState; }; /** The fight's world chat lines (one place, so the backend and the tests read the same words). */ export declare function ringFightLine(kind: 'start' | 'won' | 'failed' | 'empty', boss: RingBossKind, ring?: WorldBossRing): string; /** * Stages before this are told in chat only (notice style 'area'); from it on (half an hour out) the line pops up on screen too * ('event'). Four rings and the lairs each count down every few hours: a pop-up for "over two hours" filled the screen with * news nobody could act on (James, 2026-10-20: "the large text popups are badly done"). */ export declare const RING_POPUP_FROM_STAGE: RingStage; /** The notice style a stage's chat line goes out in. */ export declare function ringStageNoticeStyle(stage: RingStage): 'event' | 'area'; //# sourceMappingURL=worldBossRing.d.ts.map