diff --git a/BETA_TESTING.md b/BETA_TESTING.md index 7df095a..73d523a 100644 --- a/BETA_TESTING.md +++ b/BETA_TESTING.md @@ -36,8 +36,9 @@ created test transaction after its state has been inspected. dismount volumes must not constrain the broom, including after streaming into a new area. - Temporarily preserve boundary volumes through the `hmp-world` API or server admin command, reload the area, and confirm the native restriction returns; reset the policy and confirm the JSON baseline wins. -- Confirm ambient students and native enemy encounters match the configured policy for every connected - client and after world travel. +- Confirm ambient students and the Overland's enemy camps and world events match the configured policy + (none under `"suppressed"`, the same NPCs for everyone under `"server"`) for every connected client and + after world travel. - Report environment changes that affect only one client, disappear on world travel, or survive a server restart despite differing from the JSON baseline. @@ -214,24 +215,24 @@ created test transaction after its state has been inspected. ### PvP policy and duels -- Keep `hmp-pvp.defaultDecision` set to `deny`. Outside an agreed duel, cast at another player and - confirm the server vetoes the hit. -- Challenge with `/duel `. Confirm the target receives the Arcanum-default consent dialog, +- With the default rules (`who: "none"`), cast at another player outside an agreed duel and confirm + the hit does no damage. +- Challenge with `/duel #`. Confirm the target receives the Arcanum-default consent dialog, can decline it, and that the challenge expires without leaving either player busy. - While one challenge is pending, attempt another outgoing challenge and confirm exclusivity rejects it. Attempt a cross-area challenge and confirm it is rejected. - Accept a challenge and confirm neither player can damage the other during the authoritative countdown. After “Duel!”, verify mutual lock-on/homing, opponent health meters, and normal spell hits. -- Deal a finishing burst and confirm the loser remains at or above the configured health floor, - enters the retail kneel when available, and both clients restore targeting, team, meter, recovery, - and damage-immunity state afterward. +- Deal a finishing burst and confirm the loser stays at or above the configured health floor, kneels, + and the duel ends as a knock-out. Afterward both players are peaceful again, the meter is gone and + health recovers normally. - As configured staff, run `/pvp lethal on`. Confirm all connected players can mutually lock on and - ordinary relayed damage can enter the real death/respawn flow; a player joining afterward must inherit - mutual targeting after the configured delay. + ordinary relayed damage can enter the real death/respawn flow; a player joining afterward must be + hostile to everyone straight away. - While lethal mode remains on, complete another duel and confirm its finishing hit is still floored and kneel-finished. After the duel, both participants must return to open-world mutual hostility rather than - becoming untargetable. Run `/pvp lethal on` again and `/pvp sync` to exercise proxy re-fan recovery. -- Run `/pvp lethal off` and confirm third-party hits are denied and the global hostile presentation clears. + becoming untargetable. +- Run `/pvp lethal off` and confirm third-party hits are denied and players are peaceful again. Do not report the currently documented missing victim-side green-bolt cosmetic as an arbitration failure. - Exercise `/duel forfeit`, death, and disconnect. Confirm each closes the activity exactly once and the remaining player can immediately issue or accept another challenge. diff --git a/CHANGELOG.md b/CHANGELOG.md index ff02189..bfef927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ pre-`1.0.0` policy documented in [COMPATIBILITY.md](COMPATIBILITY.md#version-pol release with the pack, on its version, as a separate optional archive, `hmp-gamemodes-v.zip`. A gamemode may depend on the core pack; the core pack never depends on a gamemode, and the release check enforces it. See `gamemodes/README.md`. +- `hmp-quidditch`, the first gamemode, brings HogwartsMP's Quidditch into Foundations. The pitch is + server WorldObjects: a physics Quaffle simulated by the nearest client, a Snitch Blueprint flown by + one client and steered by the server, race-ring hoops and server-moved bludgers. Sessions use + activities, character metadata, houses, native inventory, UI and input, interactions, audio, blips + and world policy. Match control is configurable, from an `hmp-core` group to anyone, and opens to + everyone while no qualifying player is online. The manager's tuning screens are live `hmp-ui` + sliders. A called match reaches eligible players as a small unfocused card they can ignore; a key + opens the picker. +- `hmp-gauntlet`, a second gamemode: cooperative wave survival against randomized, escalating waves of native + enemies. A run is an `hmp-activities` lobby, enemies come from the `hmp-npcs` catalog, ranked records + are kept per preset and party size in `hmp-worldstate`, and personal bests in `hmp-core` character + metadata. An optional `hmp-interact` altar opens the configuration screen. Every finished run raises + `hmp-gauntlet:complete` for other resources. +- The shared typings declare the Framework's server `Chat` builtin. - `hmp-ui` input dialogs gain `range` fields: a slider with a live value readout, bounded by `min` and `max`, with an optional `step` (now also honoured by `number` fields) and `unit`. - `hmp-ui` `input()` takes an optional `{ onChange }` handler. While the dialog is open, `number`, @@ -18,14 +32,61 @@ pre-`1.0.0` policy documented in [COMPATIBILITY.md](COMPATIBILITY.md#version-pol the server, so tuning screens can apply values live and restore them on cancel. - `hmp-houses` ships a minimal client entry, like `hmp-mysql`'s, so client-bearing resources can depend on it in the shared resource dependency graph. +- `hmp-admin` no-clip gains a slow modifier for precise positioning: holding `noclip.movement.slow` + (Alt by default) multiplies speed by `noclip.slow` (`0.25` by default). Slow wins over the Shift + boost when both are held. +- `hmp-admin` no-clip can hide the administrator's body from their own screen while flying, keeping + the falling animation out of view. Set `noclip.hideLocal` to `true`; it is off by default and + needs HogwartsMP `1.9` (`LocalPlayer.setVisibility`). Visibility is restored whenever no-clip ends. +- `hmp-admin` exports `noclip.isActive(player | playerId)`, so other resources can tell whether an + administrator is no-clipping without duplicating the state. +- `hmp-loot` `suppressGameLoot` gains `pouches`, switching off the game's coin pouches ("Search Pouch"). + It needs a host with pouch suppression; an older one ignores it. + +### Changed + +- `hmp-pvp` lethal mode is now the host's PvP rules: on sets `who: "everyone"`, off sets `who: "none"`, + and the mode follows `pvpRulesChanged`. The host resolves relations for every client, so the client + targeting sync, `/pvp sync`, the `joinSyncDelayMs` setting and the client `mode.restore()` are gone. + Stopping `hmp-pvp` while lethal ends lethal mode. +- `hmp-pvp` `defaultDecision` now defaults to `"allow"`. The host drops hits between players who are not + hostile before any policy runs, so an undecided hit follows the host's own relations: a fresh server + stays consent-only under `who: "none"`, and a team, flag or pair set up elsewhere deals damage instead + of being vetoed. Set `"deny"` to keep registered rules as the only way a hit lands. +- `hmp-world`'s policy replaces `nativeEncounters` with `encounters`: `"suppressed"` (the default) or + `"server"`, where the server spawns the Overland's camps and world events as NPCs every player shares. + The server applies it through `World.setEncounterPolicy`, so clients are no longer sent it: the client + policy, `policy.current()` and `status()` carry only boundaries and population, and `status()` drops + `encounterVolumesChanged`. A config that leaves `encounters` out, or still has `nativeEncounters`, + runs suppressed. +- `hmp-quidditch` no longer switches encounters off for a match: with server camps that would despawn + every camp for everyone. ### Fixed +- `hmp-duels` works on current HogwartsMP hosts, which removed the client duel builtins it called + (`startDuelContext`, `setTargetable`, `setTeam`, `kneel`, `endDuelContext`) and drop hits between + players who are not hostile. An accepted duel is now a `Pvp.setPair` override, peaceful through the + countdown and then hostile with a kneel finish. Health recovery is held off for the fight, and the + duel ends on the host's `pvpDefeat` instead of a knock-out guessed from stale vitals. The countdown + timer is no longer unreferenced, which had left accepted duels counting down forever. The usage text + now shows that a player id takes a `#`. +- `hmp-emotes` keeps Enter in its menu: current hosts hand web views Enter's character, which also + pressed whichever button a click had left focused. +- `hmp-world`'s client no longer calls the removed client `World.setEncounterPolicy`, which failed every + policy update on current HogwartsMP hosts and left the client policy unset. - `hmp-inventory` keeps a worn item's Equipment-menu look (`appearanceOverride`, the transmog source GearID) when it saves and restores native rows, so gear no longer comes back in its own look after a reconnect. It needs a HogwartsMP host that reports the field; an older host leaves it out, as before. An override that is not a bare GearID is dropped on its own instead of failing the whole restore. +### Known issues + +- NPC kill experience does not persist. Current hosts pay kill rewards on the killer's client without + telling the server the amount, so `hmp-progression` never records it and its next synchronization sets + the experience back. Field Guide and kill-challenge progress are unaffected. See the `hmp-progression` + README for a server-side workaround. + ## [0.8.2] - 2026-10-12 ### Changed diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a6cba11..612e8bc 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -42,6 +42,7 @@ preserve them: native robe/crest character rebuild; - client `Doors` logical-lock, Alohomora, physical-door diagnostic, and streaming-aware policy builtins; - client `Breakables` repairable-object watch, listing, and streaming-aware state apply builtins with the native `breakableState` event (mod commit `59b3cfa9` or newer); +- server `World.setEncounterPolicy` (`"suppressed"` or `"server"`), the server-owned enemy encounter policy used by `hmp-world` (mod commit `c8c3fc74` or newer); - synchronized `LocalPlayer.playClip`, ability playback/cancellation, emote preview/placement, and prop/sound animation-notify replay; - client key binding, controls, position, browser UI, and resource HTTP assets. @@ -53,18 +54,17 @@ preserve them: read/write, production slot casting, and native `spellCast` events. - client `Progression` absolute point reconciliation and level-bound lookup, plus native `Talents` availability, point, purchase, grant, level, removal, refund, and reset operations. -- server `Pvp` hit policy, live-health `minHp` floors and vitals view; client mutual targetability, - team relationships, duel context, opponent meter, damage immunity and near-death kneel controls. +- server-owned `Pvp` relations: rules (`configure`, `getRules`, `pvpRulesChanged`), pair overrides + (`setPair`) and the `pvpDefeat` event; the hit policy with live-health `minHp` floors and the vitals + view; `Player.setHealthRecovery`; and the client opponent meter (mod commit `55acb616` or newer). - server `NPC.create`, NPC mutation/destruction, `NPCManager`, and `npcDied` events using the verified enemy identifiers documented by `hmp-npcs`. - server `Lootables` placement, contents, per-player views and the `lootableTaken` event, and `World.suppressLoot`, used by `hmp-loot`; a build without them leaves `hmp-loot` degraded with nothing placed. -The current PvP host has two presentation caveats: relayed Killing Curse hits do not reproduce the -victim-side green-bolt cosmetic, and target/team writes require the remote proxy to be streamed. Foundations -re-fans complete snapshots for late joiners and exposes `/pvp sync` for recovery; neither caveat weakens -server arbitration or the duel health floor. +The current PvP host has one presentation caveat: relayed Killing Curse hits do not reproduce the +victim-side green-bolt cosmetic. It does not weaken server arbitration or the duel health floor. Asynchronous resource lifecycle is not a requirement for Foundations `0.8.2`. The current pack still uses the existing synchronous lifecycle and will adopt the asynchronous contract after upstream diff --git a/Dockerfile b/Dockerfile index 1626e19..de5f13c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,8 @@ COPY resources/hmp-webhooks/package.json resources/hmp-webhooks/package.json COPY resources/hmp-world/package.json resources/hmp-world/package.json COPY resources/hmp-worldstate/package.json resources/hmp-worldstate/package.json COPY resources/hmp-loot/package.json resources/hmp-loot/package.json +COPY gamemodes/resources/hmp-gauntlet/package.json gamemodes/resources/hmp-gauntlet/package.json +COPY gamemodes/resources/hmp-quidditch/package.json gamemodes/resources/hmp-quidditch/package.json RUN npm ci COPY . . diff --git a/README.md b/README.md index d1a30ad..300e40b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The dedicated server may run on Windows x64 or Linux x86-64; Hogwarts Legacy cli Complete games built on Foundations ship separately, as optional resources a server adds on top of the core pack. They live in [`gamemodes/`](gamemodes/README.md), release with the pack on its version, and -are published as their own archive. +are published as their own archive. The first is [`hmp-quidditch`](gamemodes/resources/hmp-quidditch). ## Development diff --git a/examples/config/data/hmp-loot.json b/examples/config/data/hmp-loot.json index 1d08451..41d1168 100644 --- a/examples/config/data/hmp-loot.json +++ b/examples/config/data/hmp-loot.json @@ -10,7 +10,8 @@ "suppressGameLoot": { "chests": true, "moonstones": true, - "foragables": true + "foragables": true, + "pouches": true }, "lootables": [ { diff --git a/examples/config/data/hmp-pvp.json b/examples/config/data/hmp-pvp.json index 4d5d7d5..0c26da8 100644 --- a/examples/config/data/hmp-pvp.json +++ b/examples/config/data/hmp-pvp.json @@ -1,9 +1,8 @@ { - "defaultDecision": "deny", + "defaultDecision": "allow", "lethalMode": false, "enableCommands": true, "command": "pvp", - "joinSyncDelayMs": 3000, "adminGroups": [ { "key": "admin", "minimumGrade": 1 } ] diff --git a/examples/config/data/hmp-world.json b/examples/config/data/hmp-world.json index 7ac3d0b..ab6a48e 100644 --- a/examples/config/data/hmp-world.json +++ b/examples/config/data/hmp-world.json @@ -17,6 +17,6 @@ "policy": { "removeBoundaryVolumes": true, "ambientPopulation": false, - "nativeEncounters": false + "encounters": "suppressed" } } diff --git a/gamemodes/README.md b/gamemodes/README.md index 0e1baa3..c500984 100644 --- a/gamemodes/README.md +++ b/gamemodes/README.md @@ -3,10 +3,12 @@ Gamemodes are complete games built on HMP Foundations. They are optional: the core pack is the infrastructure every Foundations server installs, and a server adds only the gamemodes it wants on top. -No gamemodes ship yet. Each one goes in `resources/` here and gets a row below. +Each one lives in `resources/` here and has a row below. | Gamemode | Description | |---|---| +| [`hmp-gauntlet`](resources/hmp-gauntlet) | Cooperative wave survival: a party fights randomized, escalating waves of native enemies for ranked leaderboards. Built on activities, NPCs, interactions and worldstate. | +| [`hmp-quidditch`](resources/hmp-quidditch) | Quidditch on the Hogwarts pitch: called matches with house lineups, practice and Snitch hunts. Built on activities, houses, UI, input, inventory, audio, blips and world policy. | ## Versioning diff --git a/gamemodes/resources/hmp-gauntlet/LICENSE b/gamemodes/resources/hmp-gauntlet/LICENSE new file mode 100644 index 0000000..599caa3 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/LICENSE @@ -0,0 +1,3 @@ +This resource is licensed under the same terms as HMP Foundations. + +See the repository-level LICENSE file for the complete license text. diff --git a/gamemodes/resources/hmp-gauntlet/README.md b/gamemodes/resources/hmp-gauntlet/README.md new file mode 100644 index 0000000..4d6cc6a --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/README.md @@ -0,0 +1,97 @@ +# hmp-gauntlet + +`hmp-gauntlet` is a cooperative wave-survival gamemode. Players form a Foundations activity, then +fight randomized, escalating waves of real Hogwarts Legacy enemies. The gamemode owns encounter +rules, scoring, leaderboards, its configuration screen, and its HUD. Foundations owns the reusable +session, character, input, NPC, and interaction behavior. + +## Flow + +1. The host opens the configuration screen with `/gauntlet menu` or the optional altar. +2. Starting creates a public `hmp-gauntlet:survival` activity lobby at the host's position. +3. Other players use `/gauntlet join`. +4. The host uses `/gauntlet begin` to lock membership and start the waves. +5. Clearing the configured waves completes the activity. A party wipe completes it as a loss, and + `/gauntlet stop` cancels it. + +## Commands + +```text +/gauntlet menu +/gauntlet start +/gauntlet join +/gauntlet begin +/gauntlet stop +/gauntlet status +/gauntlet top [preset] +``` + +`start` creates a lobby with the default preset without opening the configuration screen. `top` is +public; the remaining commands operate on the current activity. + +## Foundations integration + +- `hmp-activities` owns lobby membership and lifecycle transitions. +- `hmp-interact` owns the optional altar, prompt, range check, and world object. +- `hmp-lib` owns command routing, configuration loading, logging, and the client control lease. +- `hmp-core` owns active-character leaderboard metadata. +- `hmp-worldstate` persists the server-wide leaderboards. +- `hmp-npcs` supplies the Framework-verified enemy catalog and owns resource-scoped NPC spawning and cleanup. +- `hmp-gauntlet` bundles its configuration screen and HUD, so it has no `hmp-ui` dependency. + +It types against each core resource's own `types.d.ts`, so a Foundations API change fails its +typecheck instead of the running server. + +## Configuration + +The defaults live in `server/config.ts`. A server can override them with `data/hmp-gauntlet.json`, or +the file `HMP_GAUNTLET_CONFIG` names; overrides are normalized before use. Supported sections are +`presets`, `defaultPreset`, `bosses`, `hidden`, `groups`, `arena`, `players`, and `interaction`. + +The enemy roster comes from the runtime `hmp-npcs` catalog, which is verified against the Framework's +native definitions. Bosses missing from the catalog are skipped. The altar is off by default; enable +`interaction.enabled` and author its `position`, `areaId`, and optional `regionId` for your map: + +```json +{ + "interaction": { + "enabled": true, + "position": { "x": 0, "y": 0, "z": 0 }, + "areaId": "Overland", + "label": "Enter the Gauntlet" + } +} +``` + +## Scaling and persistence + +Each wave scales enemy count, health, size, and boss cadence. Enemy ownership is round-robined across +connected activity participants so the pack distributes aggro. Ownership stays fixed for an enemy's +lifetime because reassignment re-embodies and heals the pawn. + +Ranked runs require an unchanged preset and the full enemy roster. Scores are separated by preset and +party-size bucket. Server records live in the `gauntlet-board` system of `hmp-worldstate` (MySQL, +server-only); per-character bests use `hmp-core` metadata under `hmp-gauntlet:bests`. + +## Events + +Every run that ends raises the server event `hmp-gauntlet:complete` once, with an +`HmpGauntletCompletion` payload (see `types.d.ts`): the result, wave, score, preset, bucket, whether +it was ranked, and the participants still connected. + +## Development + +From the repository root: + +```text +npm run typecheck -w hmp-gauntlet +npm test -w hmp-gauntlet +npm run build -w hmp-gauntlet +npm run smoke -w hmp-gauntlet +``` + +`server/main.ts`, `server/config.ts`, and `client/main.ts` are the authored sources; the build bundles +them into `dist/server.js` and `dist/client.js` and copies the two static pages beside them. + +Current gameplay limitations are one global run at a time, fixed enemy ownership, owner-only enemy +damage, and client-local HUD and configuration rendering. diff --git a/gamemodes/resources/hmp-gauntlet/client/config.html b/gamemodes/resources/hmp-gauntlet/client/config.html new file mode 100644 index 0000000..d694c48 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/client/config.html @@ -0,0 +1,77 @@ + + + + + + + +
+

THE GAUNTLET

Configure the horde, then open a survival lobby.

+
+

Difficulty

+
+ + + + +
+

Enemy pool

+
+

Full roster + unchanged preset is ranked. Custom runs are unranked.

+
+ + + diff --git a/gamemodes/resources/hmp-gauntlet/client/hud.html b/gamemodes/resources/hmp-gauntlet/client/hud.html new file mode 100644 index 0000000..5f53e61 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/client/hud.html @@ -0,0 +1,53 @@ + + + + + + + + +
+
The Gauntlet
+
+
Wave
–
+
Enemies
–
+
Score
–
+
+
+ + + diff --git a/gamemodes/resources/hmp-gauntlet/client/main.ts b/gamemodes/resources/hmp-gauntlet/client/main.ts new file mode 100644 index 0000000..7a4e49b --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/client/main.ts @@ -0,0 +1,143 @@ +import type { HmpControlLease, HmpLibClient } from "../../../../resources/hmp-lib/types"; +import type { ConfigureModel, DifficultyPreset, EnemyMenuEntry, HudState } from "../shared/types"; + +const RESOURCE = "hmp-gauntlet"; +const CONFIG_URL = "fw://resources/hmp-gauntlet/dist/config.html"; +const HUD_URL = "fw://resources/hmp-gauntlet/dist/hud.html"; +const Hmp = Imports.get("hmp-lib"); + +let menuView = -1; +let menuLease: HmpControlLease | null = null; +let model: ConfigureModel | null = null; +let hudView = -1; +let hudState: HudState = { active: false }; + +function record(value: unknown): Record | null { + if (typeof value === "string") { + try { return record(JSON.parse(value)); } catch { return null; } + } + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finite(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parsePreset(value: unknown): DifficultyPreset | null { + const source = record(value); + if (!source) return null; + return { + waves: finite(source.waves, 10), baseSpawns: finite(source.baseSpawns, 4), + spawnGrowth: finite(source.spawnGrowth, 2), hpGrowth: finite(source.hpGrowth, 1.15), + scaleStep: finite(source.scaleStep, 0.06), scaleMax: finite(source.scaleMax, 1.9), + bossEvery: finite(source.bossEvery, 5), maxAlive: finite(source.maxAlive, 8), + intermission: finite(source.intermission, 5), + }; +} + +function parseConfigureModel(value: unknown): ConfigureModel | null { + const source = record(value); + if (!source) return null; + const presets = Array.isArray(source.presets) + ? source.presets.filter((entry): entry is string => typeof entry === "string") + : []; + const sourceParams = record(source.presetParams) || {}; + const presetParams: Record = {}; + for (const name of presets) { + const parsed = parsePreset(sourceParams[name]); + if (parsed) presetParams[name] = parsed; + } + const enemies: EnemyMenuEntry[] = Array.isArray(source.enemies) ? source.enemies.flatMap((entry) => { + const enemy = record(entry); + return enemy && typeof enemy.id === "string" ? [{ + id: enemy.id, + label: typeof enemy.label === "string" ? enemy.label : enemy.id, + group: typeof enemy.group === "string" ? enemy.group : "Other", + }] : []; + }) : []; + const defaultPreset = typeof source.defaultPreset === "string" && presetParams[source.defaultPreset] + ? source.defaultPreset + : presets[0]; + return defaultPreset + ? { presets, presetParams, defaultPreset, enemies, running: source.running === true } + : null; +} + +function parseHudState(value: unknown): HudState { + const source = record(value); + if (!source || source.active !== true) return { active: false }; + return { + active: true, + wave: finite(source.wave, 0), + waves: finite(source.waves, 0), + enemies: finite(source.enemies, 0), + score: finite(source.score, 0), + }; +} + +function closeMenu(): void { + menuLease?.release(); + menuLease = null; + if (menuView < 0) return; + Web.hideView(menuView); + Web.destroyView(menuView); + menuView = -1; +} + +function pushMenuModel(): void { + if (menuView >= 0 && model) Web.emit(menuView, "configure", model); +} + +Events.on(`${RESOURCE}:configure`, (value: unknown) => { + model = parseConfigureModel(value); + if (!model) { Game.notify("[gauntlet] invalid configuration received"); return; } + if (model.running) { Game.notify("[gauntlet] a run is already active"); return; } + if (menuView >= 0) { pushMenuModel(); return; } + menuView = Web.createView(CONFIG_URL, { visible: false }); + if (menuView < 0) { Game.notify("[gauntlet] menu failed to open"); return; } + Web.on(menuView, "ready", pushMenuModel); + Web.on(menuView, "close", closeMenu); + Web.on(menuView, "start", (payload: unknown) => { + const data = record(payload); + if (!data) { Game.notify("[gauntlet] invalid menu selection"); return; } + Events.emitServer(`${RESOURCE}:start`, JSON.stringify(data)); + closeMenu(); + }); + Web.showView(menuView); + Web.focusView(menuView); + menuLease = Hmp.input.controls.acquire({ resource: RESOURCE, id: "configure" }); +}); + +function renderHud(): void { + if (hudView >= 0) Web.emit(hudView, "hud:update", hudState); +} + +function openHud(): void { + if (hudView >= 0) return; + hudView = Web.createView(HUD_URL, { visible: false }); + if (hudView < 0) return; + Web.on(hudView, "ready", renderHud); + Web.showView(hudView); +} + +function closeHud(): void { + if (hudView < 0) return; + Web.hideView(hudView); + Web.destroyView(hudView); + hudView = -1; +} + +Events.on(`${RESOURCE}:hud`, (value: unknown) => { + hudState = parseHudState(value); + if (hudState.active) { openHud(); renderHud(); } + else closeHud(); +}); + +Events.on("resourceStop", (name?: string) => { + if (!name || name === RESOURCE) { closeMenu(); closeHud(); } +}); + +console.log("[hmp-gauntlet] client ready"); diff --git a/gamemodes/resources/hmp-gauntlet/package.json b/gamemodes/resources/hmp-gauntlet/package.json new file mode 100644 index 0000000..003fd3b --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/package.json @@ -0,0 +1,34 @@ +{ + "name": "hmp-gauntlet", + "version": "0.8.2", + "private": true, + "author": "HogwartsMP", + "description": "Cooperative randomized wave survival built on HMP Foundations activities, NPCs and interactions.", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/server.js", + "scripts": { + "build": "node scripts/build.mjs", + "test": "tsx test/config.test.ts", + "typecheck": "tsc -p tsconfig.json", + "smoke": "node test/bundle.test.js" + }, + "devDependencies": { + "esbuild": "0.25.12", + "typescript": "5.9.3" + }, + "mafiahub": { + "serverScripts": ["dist/server.js"], + "clientScripts": ["dist/client.js"], + "files": ["dist/client.js", "dist/config.html", "dist/hud.html"], + "resourceDependencies": [ + { "name": "hmp-lib", "version": "0.8.2" }, + { "name": "hmp-core", "version": "0.8.2" }, + { "name": "hmp-activities", "version": "0.8.2" }, + { "name": "hmp-interact", "version": "0.8.2" }, + { "name": "hmp-worldstate", "version": "0.8.2" }, + { "name": "hmp-npcs", "version": "0.8.2" } + ], + "errorBehavior": "stop", + "priority": -300 + } +} diff --git a/gamemodes/resources/hmp-gauntlet/scripts/build.mjs b/gamemodes/resources/hmp-gauntlet/scripts/build.mjs new file mode 100644 index 0000000..f946287 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/scripts/build.mjs @@ -0,0 +1,25 @@ +import { cp, mkdir } from "node:fs/promises"; +import { build } from "esbuild"; + +await mkdir("dist", { recursive: true }); +await build({ + entryPoints: ["server/main.ts"], + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + outfile: "dist/server.js", + legalComments: "inline", +}); +await build({ + entryPoints: ["client/main.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/client.js", + legalComments: "inline", +}); +for (const name of ["config.html", "hud.html"]) { + await cp(`client/${name}`, `dist/${name}`); +} diff --git a/gamemodes/resources/hmp-gauntlet/server/config.ts b/gamemodes/resources/hmp-gauntlet/server/config.ts new file mode 100644 index 0000000..71df268 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/server/config.ts @@ -0,0 +1,156 @@ +import type { DifficultyPreset, GauntletConfig, InteractionConfig } from "../shared/types"; + +export const DEFAULT_CONFIG: GauntletConfig = { + presets: { + easy: { waves: 8, baseSpawns: 3, spawnGrowth: 1, hpGrowth: 1.10, scaleStep: 0.04, scaleMax: 1.6, bossEvery: 0, maxAlive: 6, intermission: 6 }, + normal: { waves: 10, baseSpawns: 4, spawnGrowth: 2, hpGrowth: 1.15, scaleStep: 0.06, scaleMax: 1.9, bossEvery: 5, maxAlive: 8, intermission: 5 }, + nightmare: { waves: 0, baseSpawns: 6, spawnGrowth: 3, hpGrowth: 1.22, scaleStep: 0.08, scaleMax: 2.4, bossEvery: 4, maxAlive: 12, intermission: 4 }, + }, + defaultPreset: "normal", + bosses: [ + { id: "Troll_Forest", baseHp: 1400 }, + { id: "Dragon_GreenWelsh", baseHp: 2600 }, + { id: "Ranrak", baseHp: 3500 }, + { id: "Boss_Rookwood", baseHp: 2200 }, + { id: "Boss_Harlow", baseHp: 2200 }, + { id: "Boss_Cassandra", baseHp: 2000 }, + { id: "Boss_Solomon", baseHp: 2000 }, + ], + hidden: [ + "ChompingCabbage", "VenomousTentacula", "LeapingToadstool", "Mandrake", "Flitterbloom", + "IslaMacFusty", "DeathlyNightmare", "DeathlyDreamer", "Death", + ], + groups: [ + ["Goblin", "Goblins"], ["Poacher", "Poachers"], ["Extortionist", "Rookwood"], + ["Thief", "Rookwood"], ["Wolf", "Beasts"], ["Spider", "Beasts"], + ["Dugbog", "Beasts"], ["Troll", "Beasts"], ["Inferius", "Dark"], + ["Mannequin", "Dark"], ["TombProtector", "Dark"], ["Death", "Dark"], + ], + arena: { ringRadius: 700, ringJitter: 220, baseEnemyHp: 200, intermissionDefault: 5 }, + players: { scaleFodder: true, perPlayerBonus: 0.75, maxPlayers: 8, lobbyTtlSeconds: 300 }, + interaction: { + enabled: false, + position: { x: 0, y: 0, z: 0 }, + areaId: "", + regionId: "", + radius: 300, + promptDistance: 250, + promptOffsetZ: 180, + label: "Enter the Gauntlet", + object: { + model: "/Game/Environment/BanditCamps/CampA/Meshes/Props/SM_CampA_Brazier_A.SM_CampA_Brazier_A", + collision: true, + }, + }, +}; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finite(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function preset(value: unknown, fallback: DifficultyPreset): DifficultyPreset { + const source = record(value) || {}; + return { + waves: finite(source.waves, fallback.waves), + baseSpawns: finite(source.baseSpawns, fallback.baseSpawns), + spawnGrowth: finite(source.spawnGrowth, fallback.spawnGrowth), + hpGrowth: finite(source.hpGrowth, fallback.hpGrowth), + scaleStep: finite(source.scaleStep, fallback.scaleStep), + scaleMax: finite(source.scaleMax, fallback.scaleMax), + bossEvery: finite(source.bossEvery, fallback.bossEvery), + maxAlive: finite(source.maxAlive, fallback.maxAlive), + intermission: finite(source.intermission, fallback.intermission), + }; +} + +function interaction(value: unknown): InteractionConfig { + const source = record(value) || {}; + const position = record(source.position) || {}; + const object = record(source.object); + return { + ...DEFAULT_CONFIG.interaction, + enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.interaction.enabled, + position: { + x: finite(position.x, DEFAULT_CONFIG.interaction.position.x), + y: finite(position.y, DEFAULT_CONFIG.interaction.position.y), + z: finite(position.z, DEFAULT_CONFIG.interaction.position.z), + }, + areaId: typeof source.areaId === "string" ? source.areaId : DEFAULT_CONFIG.interaction.areaId, + regionId: typeof source.regionId === "string" ? source.regionId : DEFAULT_CONFIG.interaction.regionId, + radius: finite(source.radius, DEFAULT_CONFIG.interaction.radius), + promptDistance: finite(source.promptDistance, DEFAULT_CONFIG.interaction.promptDistance), + promptOffsetZ: finite(source.promptOffsetZ, DEFAULT_CONFIG.interaction.promptOffsetZ), + label: typeof source.label === "string" && source.label.trim() ? source.label.trim() : DEFAULT_CONFIG.interaction.label, + object: object ? { + model: typeof object.model === "string" || typeof object.model === "number" + ? object.model + : DEFAULT_CONFIG.interaction.object!.model, + pitch: finite(object.pitch, 0), + yaw: finite(object.yaw, 0), + roll: finite(object.roll, 0), + scale: finite(object.scale, 1), + collision: typeof object.collision === "boolean" ? object.collision : true, + } : DEFAULT_CONFIG.interaction.object, + }; +} + +/** Normalize an optional data/hmp-gauntlet.json override without trusting its runtime shape. */ +export function normalizeConfig(value: unknown): GauntletConfig { + const source = record(value) || {}; + const sourcePresets = record(source.presets) || {}; + const presets: Record = {}; + for (const [name, fallback] of Object.entries(DEFAULT_CONFIG.presets)) { + presets[name] = preset(sourcePresets[name], fallback); + } + for (const [name, candidate] of Object.entries(sourcePresets)) { + if (!presets[name] && record(candidate)) presets[name] = preset(candidate, DEFAULT_CONFIG.presets.normal); + } + + const sourceArena = record(source.arena) || {}; + const sourcePlayers = record(source.players) || {}; + const bosses = Array.isArray(source.bosses) + ? source.bosses.flatMap((entry) => { + const item = record(entry); + return item && typeof item.id === "string" && item.id.trim() + ? [{ id: item.id.trim(), baseHp: Math.max(1, finite(item.baseHp, 2000)) }] + : []; + }) + : DEFAULT_CONFIG.bosses.map((entry) => ({ ...entry })); + const groups = Array.isArray(source.groups) + ? source.groups.flatMap((entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "string" + ? [[entry[0], entry[1]] as [string, string]] + : []) + : DEFAULT_CONFIG.groups.map(([prefix, label]): [string, string] => [prefix, label]); + const hidden = Array.isArray(source.hidden) + ? source.hidden.filter((entry): entry is string => typeof entry === "string") + : [...DEFAULT_CONFIG.hidden]; + const requestedDefault = typeof source.defaultPreset === "string" ? source.defaultPreset : DEFAULT_CONFIG.defaultPreset; + + return { + presets, + defaultPreset: presets[requestedDefault] ? requestedDefault : DEFAULT_CONFIG.defaultPreset, + bosses, + hidden, + groups, + arena: { + ringRadius: finite(sourceArena.ringRadius, DEFAULT_CONFIG.arena.ringRadius), + ringJitter: finite(sourceArena.ringJitter, DEFAULT_CONFIG.arena.ringJitter), + baseEnemyHp: finite(sourceArena.baseEnemyHp, DEFAULT_CONFIG.arena.baseEnemyHp), + intermissionDefault: finite(sourceArena.intermissionDefault, DEFAULT_CONFIG.arena.intermissionDefault), + }, + players: { + scaleFodder: typeof sourcePlayers.scaleFodder === "boolean" ? sourcePlayers.scaleFodder : DEFAULT_CONFIG.players.scaleFodder, + perPlayerBonus: finite(sourcePlayers.perPlayerBonus, DEFAULT_CONFIG.players.perPlayerBonus), + maxPlayers: finite(sourcePlayers.maxPlayers, DEFAULT_CONFIG.players.maxPlayers), + lobbyTtlSeconds: finite(sourcePlayers.lobbyTtlSeconds, DEFAULT_CONFIG.players.lobbyTtlSeconds), + }, + interaction: interaction(source.interaction), + }; +} diff --git a/gamemodes/resources/hmp-gauntlet/server/main.ts b/gamemodes/resources/hmp-gauntlet/server/main.ts new file mode 100644 index 0000000..1190e65 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/server/main.ts @@ -0,0 +1,625 @@ +import type { HmpActivities, HmpActivitySession } from "../../../../resources/hmp-activities/types"; +import type { HmpCore } from "../../../../resources/hmp-core/types"; +import type { HmpInteractServer } from "../../../../resources/hmp-interact/types"; +import type { HmpLibServer } from "../../../../resources/hmp-lib/types"; +import type { HmpNpcs } from "../../../../resources/hmp-npcs/types"; +import type { HmpWorldStateServer } from "../../../../resources/hmp-worldstate/types"; +import type { + ConfigureModel, + EnemyMenuEntry, + GauntletPlayer, + HudState, + ResolvedRunConfig, + Vector3Like, +} from "../shared/types"; +import type { HmpGauntletBucket, HmpGauntletCompletion } from "../types"; +import { normalizeConfig } from "./config"; + +const RESOURCE = "hmp-gauntlet"; +const ACTIVITY_ID = "hmp-gauntlet:survival"; +const Hmp = Imports.get>("hmp-lib"); +const Core = Imports.get>("hmp-core"); +const Activities = Imports.get>("hmp-activities"); +const Interact = Imports.get>("hmp-interact"); +const Npcs = Imports.get("hmp-npcs"); +const WorldState = Imports.get>("hmp-worldstate"); +const logger = Hmp.logger.create(RESOURCE); +const router = Hmp.command.createRouter({ prefix: "[GAUNTLET]", logger }); +const rawConfig = Hmp.config.load>(process.env.HMP_GAUNTLET_CONFIG || "data/hmp-gauntlet.json", { defaults: {} }); +const CONFIG = normalizeConfig(rawConfig); + +const catalog = Object.fromEntries(Npcs.catalog.list().map((entry) => [entry.id, entry])); +const hidden = new Set(CONFIG.hidden); +const bosses = CONFIG.bosses.filter((boss) => catalog[boss.id]); +const bossIds = new Set(bosses.map((boss) => boss.id)); +const fodderIds = Object.keys(catalog).filter((id) => !hidden.has(id) && !bossIds.has(id)); + +type Bucket = HmpGauntletBucket; +type RunPhase = "forming" | "running"; + +interface SpawnSpec { + id: string; + boss: boolean; + baseHp?: number; +} + +interface RunState { + host: GauntletPlayer; + state: RunPhase; + activitySessionId: string; + center: Vector3Like; + cfg: ResolvedRunConfig; + participants: number[]; + wave: number; + score: number; + active: Set; + all: Set; + pending: SpawnSpec[]; + remaining: number; + ownerCursor: number; + spawnIdx: number; + down: Set; + interTimer: ReturnType | null; + bucket: Bucket; + ranked: boolean; +} + +interface ScoreRecord { + score: number; + wave: number; + name: string; + count: number; + won: boolean; +} + +type CharacterBests = Record; + +// Server records live in hmp-worldstate (MySQL-backed). +// Server-only: broadcast off, since no client reads the board. +const BOARD = "gauntlet-board"; +const releaseBoard = WorldState.systems.register(BOARD, { maxEntries: 200, broadcast: false }); +const BUCKETS: Record = { solo: "Solo", duo: "Duo", party: "4 Party", free: "Free" }; +let run: RunState | null = null; +let creating = false; + +function asRecord(value: unknown): Record { + if (typeof value === "string") { + try { return asRecord(JSON.parse(value)); } + catch { return {}; } + } + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function players(): GauntletPlayer[] { + return PlayerManager.getAll() as GauntletPlayer[]; +} + +function connectedParticipantsFor(state: RunState): GauntletPlayer[] { + const connected = new Map(players().map((player) => [player.id, player])); + return state.participants.flatMap((id) => { + const player = connected.get(id); + return player ? [player] : []; + }); +} + +function connectedParticipants(): GauntletPlayer[] { + return run ? connectedParticipantsFor(run) : []; +} + +function groupOf(id: string): string { + for (const [prefix, label] of CONFIG.groups) if (id.startsWith(prefix)) return label; + return "Other"; +} + +function enemyMenu(): EnemyMenuEntry[] { + return fodderIds.map((id) => ({ id, label: catalog[id]?.label || id, group: groupOf(id) })); +} + +function pick(values: readonly T[]): T | undefined { + return values.length ? values[Math.floor(Math.random() * values.length)] : undefined; +} + +function broadcast(message: string): void { + Chat.sendToAll(message); +} + +function bucketOf(count: number): Bucket { + return count <= 1 ? "solo" : count === 2 ? "duo" : count <= 4 ? "party" : "free"; +} + +function isStandardConfig(cfg: ResolvedRunConfig): boolean { + const preset = CONFIG.presets[cfg.preset]; + if (!preset) return false; + if (cfg.waves !== preset.waves || cfg.baseSpawns !== preset.baseSpawns + || cfg.spawnGrowth !== preset.spawnGrowth || cfg.bossEvery !== preset.bossEvery) return false; + if (cfg.pool.length !== fodderIds.length) return false; + const selected = new Set(cfg.pool); + return fodderIds.every((id) => selected.has(id)); +} + +function resolveConfig(value: unknown): ResolvedRunConfig { + const raw = asRecord(value); + const requestedPreset = typeof raw.preset === "string" && CONFIG.presets[raw.preset] + ? raw.preset + : CONFIG.defaultPreset; + const preset = CONFIG.presets[requestedPreset]; + const clamp = (candidate: unknown, minimum: number, maximum: number, fallback: number): number => { + const parsed = Number(candidate); + return Number.isFinite(parsed) + ? Math.max(minimum, Math.min(maximum, Math.round(parsed))) + : fallback; + }; + let pool = Array.isArray(raw.pool) + ? raw.pool.filter((id): id is string => typeof id === "string" && fodderIds.includes(id)) + : []; + if (!pool.length) pool = [...fodderIds]; + return { + preset: requestedPreset, + waves: raw.waves !== undefined ? clamp(raw.waves, 0, 50, preset.waves) : preset.waves, + baseSpawns: raw.baseSpawns !== undefined ? clamp(raw.baseSpawns, 1, 20, preset.baseSpawns) : preset.baseSpawns, + spawnGrowth: raw.spawnGrowth !== undefined ? clamp(raw.spawnGrowth, 0, 10, preset.spawnGrowth) : preset.spawnGrowth, + bossEvery: raw.bossEvery !== undefined ? clamp(raw.bossEvery, 0, 20, preset.bossEvery) : preset.bossEvery, + hpGrowth: preset.hpGrowth, + scaleStep: preset.scaleStep, + scaleMax: preset.scaleMax, + maxAlive: Math.max(1, Math.round(preset.maxAlive)), + intermission: Math.max(1, Math.round(preset.intermission || CONFIG.arena.intermissionDefault)), + scaleFodder: CONFIG.players.scaleFodder, + perPlayerBonus: Math.max(0, CONFIG.players.perPlayerBonus), + pool, + }; +} + +Activities.definitions.register({ + id: ACTIVITY_ID, + resource: RESOURCE, + label: "The Gauntlet", + description: "A cooperative escalating wave challenge", + minimumPlayers: 1, + maximumPlayers: Math.max(1, Math.round(CONFIG.players.maxPlayers)), + requireReady: false, + requiresCharacter: true, + exclusive: true, + disconnectPolicy: "remove", + defaultVisibility: "public", + scope: "areaAndVirtualWorld", + lobbyTtlSeconds: Math.max(60, Math.round(CONFIG.players.lobbyTtlSeconds)), +}); + +function nextOwner(): number | undefined { + if (!run) return undefined; + const participants = connectedParticipants(); + if (!participants.length) return undefined; + const owner = participants[run.ownerCursor % participants.length]; + run.ownerCursor += 1; + return owner.id; +} + +function ringPosition(center: Vector3Like, index: number, count: number): Vector3Like { + const radius = CONFIG.arena.ringRadius + (Math.random() * 2 - 1) * CONFIG.arena.ringJitter; + const angle = (index / Math.max(1, count)) * Math.PI * 2 + Math.random() * 0.35; + return { + x: center.x + Math.cos(angle) * radius, + y: center.y + Math.sin(angle) * radius, + z: center.z, + }; +} + +function fodderCount(cfg: ResolvedRunConfig, wave: number): number { + const base = cfg.baseSpawns + cfg.spawnGrowth * (wave - 1); + const headcount = connectedParticipants().length || 1; + const factor = cfg.scaleFodder ? 1 + cfg.perPlayerBonus * (headcount - 1) : 1; + return Math.max(1, Math.round(base * factor)); +} + +async function startRun(host: GauntletPlayer, rawConfigValue: unknown): Promise { + if (run) { host.sendChat("[GAUNTLET] a run is already active — /gauntlet stop first"); return; } + if (creating) { host.sendChat("[GAUNTLET] another lobby is being created"); return; } + if (!fodderIds.length) { host.sendChat("[GAUNTLET] no spawnable enemies (is hmp-npcs present?)"); return; } + const cfg = resolveConfig(rawConfigValue); + creating = true; + try { + const activity = await Activities.sessions.create(host, ACTIVITY_ID, { + title: `${cfg.preset} Gauntlet`, + metadata: { preset: cfg.preset, waves: cfg.waves }, + }); + if (run) { + await Activities.sessions.cancel(activity.id, { resource: RESOURCE, reason: "another lobby completed creation first" }); + host.sendChat("[GAUNTLET] a run is already active"); + return; + } + run = { + host, + state: "forming", + activitySessionId: activity.id, + center: { x: host.position.x, y: host.position.y, z: host.position.z }, + cfg, + participants: activity.participants.map((entry) => entry.playerId), + wave: 0, + score: 0, + active: new Set(), + all: new Set(), + pending: [], + remaining: 0, + ownerCursor: 0, + spawnIdx: 0, + down: new Set(), + interTimer: null, + bucket: "solo", + ranked: isStandardConfig(cfg), + }; + broadcast(`⚔ ${host.nickname} opened a ${cfg.preset} Gauntlet lobby — /gauntlet join, then the host uses /gauntlet begin.`); + } finally { + creating = false; + } +} + +async function beginRun(host: GauntletPlayer): Promise { + if (!run || run.state !== "forming") { host.sendChat("[GAUNTLET] no forming lobby"); return; } + if (run.host.id !== host.id) { host.sendChat("[GAUNTLET] only the host can begin"); return; } + const activity = await Activities.sessions.start(run.activitySessionId, { resource: RESOURCE, actor: host }); + run.state = "running"; + run.participants = activity.participants.map((entry) => entry.playerId); + run.bucket = bucketOf(run.participants.length); + const tag = run.ranked ? `ranked · ${BUCKETS[run.bucket]}` : "custom (unranked)"; + broadcast(`⚔ THE GAUNTLET BEGINS — ${run.cfg.waves === 0 ? "endless" : `${run.cfg.waves} waves`} · ${run.cfg.preset} · ${run.participants.length} fighter(s) · ${tag}`); + pushHud(); + nextWave(); +} + +async function joinRun(player: GauntletPlayer): Promise { + if (!run || run.state !== "forming") { player.sendChat("[GAUNTLET] no lobby is accepting fighters"); return; } + const activity = await Activities.sessions.join(player, run.activitySessionId); + run.participants = activity.participants.map((entry) => entry.playerId); + broadcast(`⚔ ${Core.characters.active(player)?.name || player.nickname} joined the Gauntlet (${run.participants.length}).`); +} + +function nextWave(): void { + if (!run || run.state !== "running") return; + run.wave += 1; + if (run.cfg.waves > 0 && run.wave > run.cfg.waves) { endRun(true); return; } + if (!connectedParticipants().length) { endRun(false); return; } + + const bossWave = run.cfg.bossEvery > 0 && run.wave % run.cfg.bossEvery === 0 && bosses.length > 0; + const count = fodderCount(run.cfg, run.wave); + run.pending = []; + if (bossWave) { + const boss = pick(bosses); + if (boss) run.pending.push({ id: boss.id, boss: true, baseHp: boss.baseHp }); + } + for (let index = 0; index < count; index += 1) { + const id = pick(run.cfg.pool); + if (id) run.pending.push({ id, boss: false }); + } + run.remaining = run.pending.length; + run.spawnIdx = 0; + broadcast(waveBanner(run.wave, run.pending, bossWave)); + pushHud(); + pumpSpawns(); +} + +function pumpSpawns(): void { + if (!run) return; + while (run.pending.length && run.active.size < run.cfg.maxAlive) { + const spec = run.pending.shift(); + if (spec && !spawnOne(spec)) run.remaining -= 1; + } + if (run.remaining <= 0 && !run.pending.length && !run.active.size) intermission(); +} + +function spawnOne(spec: SpawnSpec): boolean { + if (!run) return false; + const position = ringPosition(run.center, run.spawnIdx++, 8); + const growth = Math.pow(run.cfg.hpGrowth, run.wave - 1); + const baseHp = spec.boss ? spec.baseHp || 2000 : CONFIG.arena.baseEnemyHp; + const scale = spec.boss + ? Math.min(1.4 + run.cfg.scaleStep * (run.wave - 1), run.cfg.scaleMax + 0.6) + : Math.min(1 + run.cfg.scaleStep * (run.wave - 1), run.cfg.scaleMax); + try { + const npc = Npcs.spawn({ + resource: RESOURCE, + enemyId: spec.id, + position, + disposition: "hostile", + ownerId: nextOwner(), + maxHealth: Math.round(baseHp * growth), + scale, + }); + run.active.add(npc.id); + run.all.add(npc.id); + return true; + } catch (error) { + logger.warn(`Could not spawn ${spec.id}: ${errorMessage(error)}`); + return false; + } +} + +function intermission(): void { + if (!run) return; + if (run.cfg.waves > 0 && run.wave >= run.cfg.waves) { endRun(true); return; } + broadcast(`✅ Wave ${run.wave} cleared! Score ${run.score}. Next wave in ${run.cfg.intermission}s…`); + pushHud(); + run.interTimer = setTimeout(() => { + if (!run) return; + run.interTimer = null; + nextWave(); + }, run.cfg.intermission * 1000); +} + +function destroyEnemies(state: RunState): void { + for (const id of state.all) Npcs.destroy(id, RESOURCE); +} + +function abandonRun(state: RunState): void { + if (state.interTimer !== null) clearTimeout(state.interTimer); + destroyEnemies(state); + pushHudTo(state.participants, { active: false }); +} + +// Announces a finished run to other resources as hmp-gauntlet:complete (types.d.ts). +// Resolves names while the players are still connected -- after this the run state is gone. +function emitCompletion(finished: RunState, won: boolean, cancelled: boolean): void { + try { + const roster = connectedParticipantsFor(finished).map((player) => ({ + playerId: player.id, + nickname: player.nickname, + character: Core.characters.active(player)?.name || null, + })); + const completion: HmpGauntletCompletion = { + won, + cancelled, + wave: finished.wave, + waves: finished.cfg.waves, + endless: finished.cfg.waves === 0, + score: finished.score, + preset: finished.cfg.preset, + bucket: finished.bucket, + ranked: finished.ranked, + host: finished.host?.nickname ?? null, + participants: roster, + participantCount: finished.participants.length, + activitySessionId: finished.activitySessionId, + finishedAt: new Date().toISOString(), + }; + Events.emit(`${RESOURCE}:complete`, completion); + } catch (error) { + logger.warn(`Could not emit completion: ${errorMessage(error)}`); + } +} + +function endRun(won: boolean, cancelled = false): void { + if (!run) return; + const finished = run; + run = null; + abandonRun(finished); + broadcast(won + ? `🏆 GAUNTLET CLEARED! Final score ${finished.score} across ${finished.wave} wave(s).` + : `💀 GAUNTLET OVER at wave ${finished.wave}. Final score ${finished.score}.`); + if (!cancelled) void recordScore(finished, won); + emitCompletion(finished, won, cancelled); + const terminal = cancelled || finished.state === "forming" + ? Activities.sessions.cancel(finished.activitySessionId, { resource: RESOURCE, reason: cancelled ? "run stopped" : "lobby closed" }) + : Activities.sessions.complete( + finished.activitySessionId, + { won, score: finished.score, wave: finished.wave }, + { resource: RESOURCE, reason: won ? "cleared" : "party defeated" }, + ); + void terminal.catch((error) => logger.warn(`Foundation activity close failed: ${errorMessage(error)}`)); +} + +function boardKey(preset: string, bucket: Bucket): string { + return `${preset}:${bucket}`; +} + +function parseScoreRecord(value: string | null): ScoreRecord | null { + if (!value) return null; + try { + const parsed = asRecord(JSON.parse(value)); + const score = Number(parsed.score); + const wave = Number(parsed.wave); + return Number.isFinite(score) && Number.isFinite(wave) + ? { + score, + wave, + name: typeof parsed.name === "string" ? parsed.name : "?", + count: Number(parsed.count) || 0, + won: parsed.won === true, + } + : null; + } catch { + return null; + } +} + +async function recordScore(state: RunState, won: boolean): Promise { + if (!state.ranked) return; + const key = boardKey(state.cfg.preset, state.bucket); + const previous = parseScoreRecord(WorldState.state.get(BOARD, key)); + if (!previous || state.score > previous.score) { + const next: ScoreRecord = { + score: state.score, + wave: state.wave, + // Worldstate values cap at 256 chars; bound the one free-text field. + name: (state.host.nickname || "?").slice(0, 48), + count: state.participants.length, + won, + }; + try { + await WorldState.state.set(BOARD, key, JSON.stringify(next)); + broadcast(`🥇 New ${state.cfg.preset} · ${BUCKETS[state.bucket]} record: ${state.score} pts (wave ${state.wave}) by ${next.name}!`); + } catch (error) { + logger.warn(`Could not save the ${key} record: ${errorMessage(error)}`); + } + } + + for (const player of connectedParticipantsFor(state)) { + const character = Core.characters.active(player); + if (!character) continue; + try { + const bests = await Core.metadata.getCharacter(character.id, `${RESOURCE}:bests`) || {}; + const keyName = `${state.cfg.preset}:${state.bucket}`; + if (!bests[keyName] || state.score > bests[keyName].score) { + bests[keyName] = { score: state.score, wave: state.wave }; + await Core.metadata.setCharacter(character.id, `${RESOURCE}:bests`, bests); + } + } catch (error) { + logger.warn(`Could not record ${character.name}'s Gauntlet best: ${errorMessage(error)}`); + } + } +} + +function hudModel(): HudState { + return run + ? { active: true, wave: run.wave, waves: run.cfg.waves, enemies: run.remaining, score: run.score } + : { active: false }; +} + +function pushHudTo(ids: readonly number[], model: HudState): void { + const payload = JSON.stringify(model); + for (const id of ids) { + const player = PlayerManager.getById(id) as GauntletPlayer | null; + player?.emit(`${RESOURCE}:hud`, payload); + } +} + +function pushHud(): void { + if (run) pushHudTo(run.participants, hudModel()); +} + +function waveBanner(wave: number, specs: readonly SpawnSpec[], bossWave: boolean): string { + const counts: Record = {}; + for (const spec of specs) { + const label = catalog[spec.id]?.label || spec.id; + counts[label] = (counts[label] || 0) + 1; + } + const composition = Object.entries(counts) + .map(([label, count]) => count > 1 ? `${count}× ${label}` : label) + .join(", "); + return `${bossWave ? "☠ BOSS WAVE" : "⚔ WAVE"} ${wave} · ${specs.length} enemies — ${composition}`; +} + +Events.on("npcDied", (npcId: number) => { + if (!run || !run.active.has(npcId)) return; + run.active.delete(npcId); + run.score += 1; + run.remaining -= 1; + pushHud(); + pumpSpawns(); +}); + +function markDown(player: GauntletPlayer): void { + if (!run || !run.participants.includes(player.id)) return; + run.down.add(player.id); + if (!connectedParticipants().some((participant) => !run!.down.has(participant.id))) { + broadcast("💀 All fighters have fallen!"); + endRun(false); + } +} + +Events.on("playerDied", markDown); +Events.on("playerDowned", markDown); +Events.on("playerDownedEnd", (player: GauntletPlayer) => { run?.down.delete(player.id); }); + +function openMenu(player: GauntletPlayer): void { + const model: ConfigureModel = { + presets: Object.keys(CONFIG.presets), + presetParams: CONFIG.presets, + defaultPreset: CONFIG.defaultPreset, + enemies: enemyMenu(), + running: Boolean(run), + }; + player.emit(`${RESOURCE}:configure`, JSON.stringify(model)); +} + +Events.onClient(`${RESOURCE}:start`, (player: HogwartsMpPlayer, data: unknown) => { + void startRun(player as GauntletPlayer, data).catch((error) => { + (player as GauntletPlayer).sendChat(`[GAUNTLET] ${errorMessage(error)}`); + }); +}); + +function showLeaderboard(player: GauntletPlayer, presetArgument?: string): void { + const preset = (presetArgument || CONFIG.defaultPreset).toLowerCase(); + if (!CONFIG.presets[preset]) { + player.sendChat(`[GAUNTLET] unknown preset '${preset}' — ${Object.keys(CONFIG.presets).join(", ")}`); + return; + } + player.sendChat(`[GAUNTLET] ${preset} leaderboards (party size):`); + for (const bucket of Object.keys(BUCKETS) as Bucket[]) { + const score = parseScoreRecord(WorldState.state.get(BOARD, boardKey(preset, bucket))); + player.sendChat(` ${BUCKETS[bucket].padEnd(7)} — ${score ? `${score.score} pts (wave ${score.wave}) by ${score.name}` : "—"}`); + } +} + +router.register("gauntlet", async ({ player, args }) => { + const verb = (args[0] || "").toLowerCase(); + if (verb === "top") { showLeaderboard(player, args[1]); return; } + if (verb === "menu" || !verb) { openMenu(player); return; } + if (verb === "start") { await startRun(player, {}); return; } + if (verb === "join") { await joinRun(player); return; } + if (verb === "begin") { await beginRun(player); return; } + if (verb === "stop") { if (run) endRun(false, true); else player.sendChat("[GAUNTLET] no run active"); return; } + if (verb === "status") { + if (!run) { player.sendChat("[GAUNTLET] idle"); return; } + player.sendChat(`[GAUNTLET] ${run.state} · wave ${run.wave}${run.cfg.waves ? `/${run.cfg.waves}` : ""} · ${run.active.size} alive · ${run.remaining} left · score ${run.score} · ${run.ranked ? `ranked ${BUCKETS[run.bucket]}` : "custom"}`); + return; + } + player.sendChat("Usage: /gauntlet menu | start | join | begin | stop | status | top [preset]"); +}); + +Events.on("chatCommand", (player: HogwartsMpPlayer, message: string, command: string, args: string[]) => { + void router.handle(player as GauntletPlayer, message, command, args); +}); + +function syncActivity({ session }: { session: HmpActivitySession }): void { + if (!run || session.id !== run.activitySessionId) return; + run.participants = session.participants.map((entry) => entry.playerId); + const leader = players().find((player) => player.id === session.leaderPlayerId); + if (leader) run.host = leader; + if (run.state === "running" && !run.participants.length) endRun(false); +} + +Events.on("hmp:activities:joined", syncActivity); +Events.on("hmp:activities:left", syncActivity); +Events.on("hmp:activities:updated", syncActivity); +Events.on("hmp:activities:cancelled", ({ session }: { session: HmpActivitySession }) => { + if (!run || session.id !== run.activitySessionId) return; + const abandoned = run; + run = null; + abandonRun(abandoned); +}); + +const altar = CONFIG.interaction; +if (altar.enabled) { + Interact.register({ + id: `${RESOURCE}:altar`, + resource: RESOURCE, + label: altar.label, + position: altar.position, + radius: altar.radius, + promptDistance: altar.promptDistance, + promptOffsetZ: altar.promptOffsetZ, + areaId: altar.areaId || undefined, + regionId: altar.regionId || undefined, + requirements: { character: true }, + object: altar.object, + handler: ({ player }) => openMenu(player), + }); +} + +Events.on("resourceStop", (name?: string) => { + if (name && name !== RESOURCE) return; + releaseBoard(); + if (run) { + const abandoned = run; + run = null; + abandonRun(abandoned); + } +}); + +logger.info(`ready — ${fodderIds.length} fodder type(s), ${bosses.length} boss(es)`); diff --git a/gamemodes/resources/hmp-gauntlet/shared/types.ts b/gamemodes/resources/hmp-gauntlet/shared/types.ts new file mode 100644 index 0000000..825496e --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/shared/types.ts @@ -0,0 +1,97 @@ +import type { HmpInteractionWorldObject } from "../../../../resources/hmp-interact/types"; + +export interface Vector3Like { + x: number; + y: number; + z: number; +} + +export interface GauntletPlayer { + id: number; + nickname: string; + connected?: boolean; + position: Vector3Like; + virtualWorld?: number; + emit(eventName: string, payload?: unknown): void; + sendChat(message: string): void; + location?(): ({ areaId: string; regionId: string } & Vector3Like) | null; +} + +export interface DifficultyPreset { + waves: number; + baseSpawns: number; + spawnGrowth: number; + hpGrowth: number; + scaleStep: number; + scaleMax: number; + bossEvery: number; + maxAlive: number; + intermission: number; +} + +export interface BossDefinition { + id: string; + baseHp: number; +} + +export interface InteractionConfig { + enabled: boolean; + position: Vector3Like; + areaId?: string; + regionId?: string; + radius: number; + promptDistance: number; + promptOffsetZ: number; + label: string; + object?: HmpInteractionWorldObject; +} + +export interface GauntletConfig { + presets: Record; + defaultPreset: string; + bosses: BossDefinition[]; + hidden: string[]; + groups: Array<[prefix: string, label: string]>; + arena: { + ringRadius: number; + ringJitter: number; + baseEnemyHp: number; + intermissionDefault: number; + }; + players: { + scaleFodder: boolean; + perPlayerBonus: number; + maxPlayers: number; + lobbyTtlSeconds: number; + }; + interaction: InteractionConfig; +} + +export interface ResolvedRunConfig extends DifficultyPreset { + preset: string; + scaleFodder: boolean; + perPlayerBonus: number; + pool: string[]; +} + +export interface EnemyMenuEntry { + id: string; + label: string; + group: string; +} + +export interface ConfigureModel { + presets: string[]; + presetParams: Record; + defaultPreset: string; + enemies: EnemyMenuEntry[]; + running: boolean; +} + +export interface HudState { + active: boolean; + wave?: number; + waves?: number; + enemies?: number; + score?: number; +} diff --git a/gamemodes/resources/hmp-gauntlet/test/bundle.test.js b/gamemodes/resources/hmp-gauntlet/test/bundle.test.js new file mode 100644 index 0000000..ec3f3e5 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/test/bundle.test.js @@ -0,0 +1,185 @@ +const assert = require("node:assert"); +const path = require("node:path"); + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const logger = { debug() {}, info() {}, warn() {}, error() {} }; +const commands = new Map(); +const serverHandlers = new Map(); +const chat = []; +const emitted = []; +const timers = []; + +const makePlayer = (id) => ({ + id, nickname: `Player ${id}`, position: { x: 100, y: 200, z: 300 }, sent: [], messages: [], + emit(event, payload) { this.sent.push({ event, payload: JSON.parse(payload) }); }, + sendChat(message) { this.messages.push(message); }, +}); +const host = makePlayer(1); +const guest = makePlayer(2); +const players = [host, guest]; + +const spawned = []; +const destroyed = []; +const npcs = { + catalog: { + list: () => [ + { id: "Goblin_Melee", label: "Goblin" }, + { id: "Spider_Small", label: "Spider" }, + { id: "Troll_Forest", label: "Forest Troll" }, + { id: "Mandrake", label: "Mandrake" }, + ], + }, + spawn: (input) => { const npc = { id: 1000 + spawned.length, enemyId: input.enemyId }; spawned.push({ ...input, id: npc.id }); return npc; }, + destroy: (id, resource) => { destroyed.push({ id, resource }); return true; }, + clear: () => 0, +}; + +const definitions = []; +const lifecycle = []; +let session = null; +const activities = { + definitions: { register: (definition) => { definitions.push(definition); return async () => 1; } }, + sessions: { + create: async (player, activityId, input) => { + session = { id: `session-${lifecycle.length}`, activityId, leaderPlayerId: player.id, participants: [{ playerId: player.id }], input }; + lifecycle.push({ type: "create", id: session.id }); + return session; + }, + join: async (player, id) => { session.participants.push({ playerId: player.id }); lifecycle.push({ type: "join", id }); return session; }, + start: async (id) => { lifecycle.push({ type: "start", id }); return session; }, + complete: async (id, result) => { lifecycle.push({ type: "complete", id, result }); return session; }, + cancel: async (id, context) => { lifecycle.push({ type: "cancel", id, reason: context.reason }); return session; }, + get: () => session, + }, +}; + +const board = new Map(); +const bests = new Map(); +const services = { + "hmp-lib": { + logger: { create: () => logger }, + config: { load: (_candidates, options) => options.defaults }, + command: { createRouter: () => ({ register: (name, handler) => { commands.set(name, handler); return () => true; }, handle: async () => false }) }, + }, + "hmp-core": { + characters: { active: (player) => ({ id: player.id * 10, name: `Character ${player.id}` }) }, + metadata: { + getCharacter: async (id, key) => bests.get(`${id}:${key}`), + setCharacter: async (id, key, value) => { bests.set(`${id}:${key}`, value); return value; }, + }, + }, + "hmp-activities": activities, + "hmp-interact": { register: () => { throw new Error("the altar is disabled by default"); } }, + "hmp-worldstate": { + systems: { register: (name) => { assert.strictEqual(name, "gauntlet-board"); return () => true; } }, + state: { get: (_system, key) => board.get(key) ?? null, set: async (_system, key, value) => { board.set(key, value); return true; } }, + }, + "hmp-npcs": npcs, +}; + +global.Imports = { get: (name) => services[name] }; +global.PlayerManager = { getAll: () => players, getById: (id) => players.find((player) => player.id === id) || null }; +global.Chat = { sendToAll: (message) => chat.push(message) }; +global.Events = { + on: (name, handler) => serverHandlers.set(name, handler), + onClient: (name, handler) => serverHandlers.set(`client:${name}`, handler), + emit: (name, payload) => emitted.push({ name, payload }), +}; +const realSetTimeout = global.setTimeout; +global.setTimeout = (handler) => { timers.push(handler); return { unref() {} }; }; + +async function runServer() { + require(path.resolve(__dirname, "..", "dist", "server.js")); + const gauntlet = commands.get("gauntlet"); + assert.ok(gauntlet, "the /gauntlet command must be registered"); + assert.strictEqual(definitions[0].id, "hmp-gauntlet:survival"); + for (const name of ["npcDied", "playerDied", "playerDowned", "playerDownedEnd", "chatCommand", "resourceStop", "client:hmp-gauntlet:start", "hmp:activities:cancelled"]) { + assert.ok(serverHandlers.has(name), `missing ${name} handler`); + } + + await gauntlet({ player: host, args: ["menu"] }); + const menu = host.sent.find((entry) => entry.event === "hmp-gauntlet:configure").payload; + assert.deepStrictEqual(menu.enemies.map((entry) => entry.id), ["Goblin_Melee", "Spider_Small"], "bosses and hidden enemies stay out of the pool"); + assert.deepStrictEqual(menu.presets, ["easy", "normal", "nightmare"]); + + await gauntlet({ player: host, args: ["start"] }); + await gauntlet({ player: guest, args: ["join"] }); + await gauntlet({ player: host, args: ["begin"] }); + assert.deepStrictEqual(lifecycle.map((entry) => entry.type), ["create", "join", "start"]); + + // Normal wave 1: 4 enemies, scaled by 1.75 for the second fighter, owners alternating. + assert.strictEqual(spawned.length, 7); + assert.ok(spawned.every((npc) => npc.resource === "hmp-gauntlet" && npc.disposition === "hostile" && npc.maxHealth === 200)); + assert.deepStrictEqual(spawned.slice(0, 4).map((npc) => npc.ownerId), [1, 2, 1, 2]); + assert.deepStrictEqual(host.sent.at(-1), { event: "hmp-gauntlet:hud", payload: { active: true, wave: 1, waves: 10, enemies: 7, score: 0 } }); + + for (const npc of [...spawned]) serverHandlers.get("npcDied")(npc.id); + assert.strictEqual(timers.length, 1, "a cleared wave schedules the intermission"); + timers.shift()(); + assert.strictEqual(spawned.length, 7 + 8, "wave 2 spawns up to maxAlive"); + + serverHandlers.get("playerDied")(host); + serverHandlers.get("playerDied")(guest); + await tick(); + assert.deepStrictEqual(lifecycle.at(-1).result, { won: false, score: 7, wave: 2 }); + assert.deepStrictEqual(destroyed.map((entry) => entry.id).sort(), spawned.map((npc) => npc.id).sort()); + assert.deepStrictEqual(guest.sent.at(-1), { event: "hmp-gauntlet:hud", payload: { active: false } }); + const completion = emitted.find((entry) => entry.name === "hmp-gauntlet:complete").payload; + assert.strictEqual(completion.ranked, true); + assert.strictEqual(completion.bucket, "duo"); + assert.deepStrictEqual(completion.participants.map((entry) => entry.character), ["Character 1", "Character 2"]); + assert.strictEqual(JSON.parse(board.get("normal:duo")).score, 7); + assert.deepStrictEqual(bests.get("20:hmp-gauntlet:bests"), { "normal:duo": { score: 7, wave: 2 } }); + + await gauntlet({ player: guest, args: ["top"] }); + assert.ok(guest.messages.some((line) => line.includes("7 pts (wave 2) by Player 1"))); + + // A menu run with a trimmed pool is custom: never ranked, and a stop cancels the activity. + serverHandlers.get("client:hmp-gauntlet:start")(host, JSON.stringify({ preset: "easy", pool: ["Goblin_Melee"] })); + await tick(); + await gauntlet({ player: host, args: ["stop"] }); + await tick(); + assert.strictEqual(lifecycle.at(-1).type, "cancel"); + assert.strictEqual(emitted.at(-1).payload.cancelled, true); + assert.strictEqual(emitted.at(-1).payload.ranked, false); + assert.strictEqual(board.size, 1); +} + +function runClient() { + const handlers = new Map(); + const views = new Map(); + const toServer = []; + let released = 0; + global.Events = { on: (name, handler) => handlers.set(name, handler), emitServer: (name, payload) => toServer.push({ name, payload }) }; + global.Imports = { get: () => ({ input: { controls: { acquire: () => ({ release: () => { released += 1; return true; } }) } } }) }; + global.Game = { notify: (message) => { throw new Error(`unexpected notice: ${message}`); } }; + global.Web = { + createView: (url) => { const id = views.size + 1; views.set(id, { url, listeners: new Map(), destroyed: false }); return id; }, + on: (id, name, listener) => views.get(id).listeners.set(name, listener), + emit: () => true, + showView: () => true, + hideView: () => true, + focusView: () => true, + destroyView: (id) => { views.get(id).destroyed = true; return true; }, + }; + require(path.resolve(__dirname, "..", "dist", "client.js")); + + const preset = { waves: 10, baseSpawns: 4, spawnGrowth: 2, hpGrowth: 1.15, scaleStep: 0.06, scaleMax: 1.9, bossEvery: 5, maxAlive: 8, intermission: 5 }; + handlers.get("hmp-gauntlet:configure")(JSON.stringify({ presets: ["normal"], presetParams: { normal: preset }, defaultPreset: "normal", enemies: [], running: false })); + const menu = views.get(1); + assert.strictEqual(menu.url, "fw://resources/hmp-gauntlet/dist/config.html"); + menu.listeners.get("start")({ preset: "normal", pool: [] }); + assert.deepStrictEqual(toServer, [{ name: "hmp-gauntlet:start", payload: JSON.stringify({ preset: "normal", pool: [] }) }]); + assert.ok(menu.destroyed && released === 1, "starting closes the menu and returns the controls"); + + handlers.get("hmp-gauntlet:hud")(JSON.stringify({ active: true, wave: 1, waves: 10, enemies: 7, score: 0 })); + assert.strictEqual(views.get(2).url, "fw://resources/hmp-gauntlet/dist/hud.html"); + handlers.get("hmp-gauntlet:hud")(JSON.stringify({ active: false })); + assert.ok(views.get(2).destroyed); +} + +runServer() + .then(runClient) + .then(() => console.log("hmp-gauntlet bundle contract passed")) + .finally(() => { global.setTimeout = realSetTimeout; }) + .catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/gamemodes/resources/hmp-gauntlet/test/config.test.ts b/gamemodes/resources/hmp-gauntlet/test/config.test.ts new file mode 100644 index 0000000..a82a4aa --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/test/config.test.ts @@ -0,0 +1,39 @@ +import assert = require("node:assert"); +import { DEFAULT_CONFIG, normalizeConfig } from "../server/config"; + +const defaults = normalizeConfig(undefined); +assert.deepStrictEqual(defaults.presets, DEFAULT_CONFIG.presets); +assert.strictEqual(defaults.defaultPreset, "normal"); +assert.deepStrictEqual(defaults.bosses, DEFAULT_CONFIG.bosses); +assert.notStrictEqual(defaults.bosses, DEFAULT_CONFIG.bosses, "defaults must be copied, not shared"); +assert.strictEqual(defaults.interaction.enabled, false); +assert.deepStrictEqual(normalizeConfig("not an object").presets, DEFAULT_CONFIG.presets); + +// A partial preset keeps the rest of its own defaults; a new preset falls back to normal's. +const presets = normalizeConfig({ presets: { easy: { waves: 3, maxAlive: "x" }, insane: { waves: 2, baseSpawns: 9 }, junk: 4 }, defaultPreset: "insane" }); +assert.deepStrictEqual(presets.presets.easy, { ...DEFAULT_CONFIG.presets.easy, waves: 3 }); +assert.deepStrictEqual(presets.presets.insane, { ...DEFAULT_CONFIG.presets.normal, waves: 2, baseSpawns: 9 }); +assert.ok(!("junk" in presets.presets)); +assert.strictEqual(presets.defaultPreset, "insane"); +assert.strictEqual(normalizeConfig({ defaultPreset: "missing" }).defaultPreset, "normal"); + +const lists = normalizeConfig({ + bosses: [{ id: " Ranrak ", baseHp: -5 }, { id: "" }, { baseHp: 100 }, "Troll"], + groups: [["Goblin", "Goblins"], ["Wolf"], [1, "Beasts"]], + hidden: ["Mandrake", 7], +}); +assert.deepStrictEqual(lists.bosses, [{ id: "Ranrak", baseHp: 1 }]); +assert.deepStrictEqual(lists.groups, [["Goblin", "Goblins"]]); +assert.deepStrictEqual(lists.hidden, ["Mandrake"]); + +const altar = normalizeConfig({ + interaction: { enabled: true, position: { x: 10, y: "y" }, areaId: "Overland", label: " ", object: { scale: 2 } }, +}).interaction; +assert.strictEqual(altar.enabled, true); +assert.deepStrictEqual(altar.position, { x: 10, y: 0, z: 0 }); +assert.strictEqual(altar.areaId, "Overland"); +assert.strictEqual(altar.label, DEFAULT_CONFIG.interaction.label); +assert.deepStrictEqual(altar.object, { model: DEFAULT_CONFIG.interaction.object!.model, pitch: 0, yaw: 0, roll: 0, scale: 2, collision: true }); +assert.deepStrictEqual(normalizeConfig({ interaction: {} }).interaction.object, DEFAULT_CONFIG.interaction.object); + +console.log("hmp-gauntlet config tests passed"); diff --git a/gamemodes/resources/hmp-gauntlet/tsconfig.json b/gamemodes/resources/hmp-gauntlet/tsconfig.json new file mode 100644 index 0000000..79216a2 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["server/**/*.ts", "client/**/*.ts", "shared/**/*.ts", "test/**/*.test.ts", "types.d.ts", "../../../types/**/*.d.ts"] +} diff --git a/gamemodes/resources/hmp-gauntlet/types.d.ts b/gamemodes/resources/hmp-gauntlet/types.d.ts new file mode 100644 index 0000000..3576839 --- /dev/null +++ b/gamemodes/resources/hmp-gauntlet/types.d.ts @@ -0,0 +1,31 @@ +/** Party-size bracket a ranked run is scored in. */ +export type HmpGauntletBucket = "solo" | "duo" | "party" | "free"; + +export interface HmpGauntletParticipant { + playerId: number; + nickname: string; + /** The active hmp-core character's name, or null when none is selected. */ + character: string | null; +} + +/** Payload of the server `hmp-gauntlet:complete` event, raised once for every run that ends. */ +export interface HmpGauntletCompletion { + won: boolean; + /** Stopped with `/gauntlet stop`; cancelled runs are never scored. */ + cancelled: boolean; + wave: number; + /** Configured wave count; 0 for an endless run. */ + waves: number; + endless: boolean; + score: number; + preset: string; + bucket: HmpGauntletBucket; + ranked: boolean; + host: string | null; + /** Participants still connected when the run ended. */ + participants: HmpGauntletParticipant[]; + /** Everyone who took part, connected or not. */ + participantCount: number; + activitySessionId: string; + finishedAt: string; +} diff --git a/gamemodes/resources/hmp-quidditch/LICENSE b/gamemodes/resources/hmp-quidditch/LICENSE new file mode 100644 index 0000000..599caa3 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/LICENSE @@ -0,0 +1,3 @@ +This resource is licensed under the same terms as HMP Foundations. + +See the repository-level LICENSE file for the complete license text. diff --git a/gamemodes/resources/hmp-quidditch/README.md b/gamemodes/resources/hmp-quidditch/README.md new file mode 100644 index 0000000..f324362 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/README.md @@ -0,0 +1,131 @@ +# hmp-quidditch + +`hmp-quidditch` is the Foundations version of the HogwartsMP Quidditch resource. It keeps the +server-authoritative pitch simulation: the physical Quaffle, the flying Snitch, hoops, bludgers, roles, +scoring, reconnectable seats, goal banners, scoreboard and results overlay. Its shared concerns run on +Foundations. + +## The pitch + +Everything on the pitch is a server `WorldObject`: + +- **Quaffle**: a physics object. The nearest client simulates it while it is loose and reports its pose + back. A carrier holds it attached to their hand, and a throw sets its velocity. Goals are a segment + test against the hoop planes, run on the server from that pose. +- **Snitch**: the game's `BP_Loose_Snitch` Blueprint, flown by one elected client for everyone. The + server's flight model only steers it, and a catch is judged against where its simulator says it is. +- **Hoops**: race-ring Blueprints placed from the pitch geometry. +- **Bludgers**: moved by the server every tick; every client interpolates them. + +No client reports where anything is, so no client can move the ball or the snitch for anyone else. + +## Foundations integration + +- `hmp-activities` mirrors forming and running sessions for discovery, exclusivity, and lifecycle. +- `hmp-core` stores career goal and Snitch totals against the active character, and its groups decide + who may manage a match. +- `hmp-houses` supplies and synchronizes character house membership used by fixtures. +- `hmp-inventory` checks and grants the configured native broom through the persistent native API. +- `hmp-ui` renders the manager, fixture, position-selection and live tuning dialogs, and notifications. +- `hmp-lib` owns client shortcuts, logging, and shared resource cleanup. +- `hmp-interact` owns the pitch interaction instead of the legacy global `interactables` resource. +- `hmp-audio` owns stadium ambience and gameplay cues. +- `hmp-blips` publishes the Quaffle, Snitch, and bludger markers from the server's own positions. +- `hmp-world` owns world policy; client teardown restores the latest one. A match leaves the server-wide + encounter policy alone: server camps only spawn when players approach them, and switching them off would + despawn every camp for everyone. + +The resource-specific, non-focused banner, scoreboard, and results overlays remain local. They are +flight HUDs rather than modal application UI, so they deliberately do not take mouse or keyboard focus. + +## Modes + +| Mode | Players | Description | +|---|---:|---| +| `snitchHunt` | 1+ | Seekers catch the Snitch by remaining inside its catch radius. | +| `practice` | 1+ | Hoops and a Quaffle without bludgers or a Snitch. | +| `match` | 2+ | Chasers, beaters, keepers, seekers, goals, bludgers, and a match-ending Snitch. | + +## Calling a match + +`/quidditch call` (or the manager) tells the eligible players with a small card on the right of +their screen, nothing more: the fixture, the mode, how long the call stays open, how full each side is, +and their own pick. It never takes focus, so anyone who does not want to play can ignore it. It goes +away when the call is started or called off. Pressing the card's key (`lobby.joinKey`, default `J`) +opens the position picker; so do `/quidditch join` and the pitch interaction. The referee starts or +calls off the match from the same picker. + +## Match control + +Calling, starting, stopping and tuning a match is gated by `control` in `quidditch.config.js`: + +```js +control: { + who: "staff", // or "anyone" + group: "admin", // the hmp-core group that counts as staff + minimumGrade: 1, + anyoneWhenNoStaff: true, // nobody qualifying online: anyone may run a match +}, +``` + +Taking a seat, picking a team or position, and playing are never gated. + +## Manager + +`/quidditch manager` opens the manager: call a match, start one immediately, open the lobby, stop the +session, or go to the pitch. It also holds: + +- **Tune the pitch**: one dialog per section (Snitch, Bludgers, Keeper, Bat, Banners, Hoops) of sliders + that apply live as they are dragged. **Keep** leaves them; **Revert** or closing puts the section back. +- **Snitch controls**: freeze (the catch test keeps running), bring it to you, release it now, reset a + catch, or toggle its marker. +- **Bludger controls**: freeze them, or toggle their markers. +- **Beater bat**: choose the bat and the socket it rides. + +Tuned values last until the resource restarts; `quidditch.config.js` holds the defaults. + +## Commands + +```text +/quidditch +/quidditch manager +/quidditch call [mode] +/quidditch call ffa [mode] +/quidditch lobby +/quidditch join +/quidditch start [solo] +/quidditch stop +/quidditch team +/quidditch role +/quidditch pitch +``` + +Closed-test tuning commands from the original resource remain available, including `ball`, `cast`, +`hoops`, `hoopheight`, `trail`, `bludgers`, `bat`, `banner`, `bannertest`, `results`, `ballwhere`, and +`unstick`. The ones that change a match follow the match-control rule above. + +Default in-match shortcuts are `E` pickup, `Q` pass, `R` shoot, `V` swing, `B` block, and `T` tackle. +They are registered through `hmp-lib`; the server revalidates range, role, possession, and session +state for every action. + +## Runtime exports + +- `status()` +- `call(referee, mode?, teamA?, teamB?, ffa?)` +- `start(starter, mode?, solo?)` +- `stop(reason?)` + +## Build and verification + +From the Foundations repository root: + +```text +npm install +npm run build -w hmp-quidditch +npm test -w hmp-quidditch +npm run smoke -w hmp-quidditch +``` + +`npm test` runs the integration contract and a behaviour test of the server script against fakes. The +pitch needs a HogwartsMP build whose `WorldObject` supports physics objects, carried objects and +simulated Blueprints (`physics`, `simulated`, `attachTo`, `setVelocity`). diff --git a/gamemodes/resources/hmp-quidditch/client/banner.html b/gamemodes/resources/hmp-quidditch/client/banner.html new file mode 100644 index 0000000..ed78587 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/client/banner.html @@ -0,0 +1,135 @@ + +Quidditch Banner + + +
+ + diff --git a/gamemodes/resources/hmp-quidditch/client/lobby.html b/gamemodes/resources/hmp-quidditch/client/lobby.html new file mode 100644 index 0000000..fed379e --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/client/lobby.html @@ -0,0 +1,120 @@ + +Quidditch Call + + +
+
Quidditch called--:--
+
+
+
+
+
+ + diff --git a/gamemodes/resources/hmp-quidditch/client/main.js b/gamemodes/resources/hmp-quidditch/client/main.js new file mode 100644 index 0000000..e02d2d0 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/client/main.js @@ -0,0 +1,522 @@ +/** + * quidditch resource — client script (the renderer). + * + * The server decides everything; this file draws it. The pitch itself (ball, hoops, snitch, + * bludgers) is server WorldObjects and the markers are hmp-blips, so what is left here is the + * overlays, the keybinds, the bat and the knock reaction. It reports aim and is never asked for a + * verdict. + * + * Two engine facts drive the odd-looking bits below: + * - A relayed Able ability can carry m_Length = +inf and park on its channel forever, because the + * Behaviour Tree that normally ends it is not involved. Every playAbility here has a paired + * cancelAbilityHandle on a timer. + * - Casting while broom-mounted is off in the stock game (only Revelio). Broom.setCastingAllowed + * flips the GameLogic BoolVar that frees it — per client, per session, no save write. + */ + +const RESOURCE = "hmp-quidditch"; +const Hmp = Imports.get("hmp-lib"); +const FoundationWorld = Imports.get("hmp-world"); +const logger = Hmp.logger.create(RESOURCE); + +logger.info("Client loading"); + +let session = null; +let carrying = false; // whether this client holds the quaffle, read off its WorldObject +let ballNetId = 0; // the server quaffle's WorldObject id +// Per CHANNEL, not one shared handle. Every ability here carries m_Length = +inf and needs a paired +// cancel; with a single timer, a throw followed by a bludger hit clears the first timer and only the +// second channel is ever released. Today they all happen to use PartialBody, so it is survivable — +// but it survives by luck, and the config lets a channel be changed. +const cancelTimers = new Map(); +let batOn = false; // whether a held prop (the beater bat) is currently equipped + +const on = (event, handler) => Events.on(event, (raw) => { + let data = {}; + try { data = typeof raw === "string" ? JSON.parse(raw || "{}") : (raw || {}); } catch (_) {} + try { handler(data); } catch (err) { console.error(`[QUIDDITCH] ${event}: ${err}`); } +}); + +function teardown() { + closeBanner(); + closeScoreboard(); + closeResults(); + ballNetId = 0; + dropBat(); + if (mountNag) { clearInterval(mountNag); mountNag = null; } + if (stunTimer) { clearTimeout(stunTimer); stunTimer = null; } + if (stunHeld) { try { Broom.setInputDisabled(false); } catch (_) {} stunHeld = false; } + try { LocalPlayer.clearProp(); } catch (_) {} + if (session) { + if (session.unlockCasting) { try { Broom.setCastingAllowed(false); } catch (_) {} } + if (session.liftLimits) { + try { Broom.clearMaxHeight(); } catch (_) {} + } + } + session = null; + carrying = false; + try { FoundationWorld.policy.restore(); } catch (_) {} +} + +let mountNag = null; + +/** + * Ask people to get on their brooms. There is no way to do it FOR them: the local mount runs + * through ABroomItemTool into AFlyingBroom.PlayerMountAbility, and nothing the mod exposes + * reaches either. So the pitch puts everyone on the deck and this says the rest. + * + * It ASKS before it speaks -- Broom.mounted() is cheap and already exposed -- so somebody who + * arrived flying is never told to do what they have done. Three tries, then it gives up: a + * message that will not stop is worse than no message. + */ +function nudgeToMount() { + if (mountNag) { clearInterval(mountNag); mountNag = null; } + let left = 3; + const ask = () => { + if (!session) { clearInterval(mountNag); mountNag = null; return; } + let up = false; + try { up = !!Broom.mounted(); } catch (_) { up = true; } // unreadable: assume flying, say nothing + if (up || left-- <= 0) { clearInterval(mountNag); mountNag = null; return; } + Game.notify("Mount your broom to play."); + }; + ask(); + mountNag = setInterval(ask, 5000); +} + +on("quidditch:setup", (data) => { + teardown(); + session = data; + + // Both are per-client session state, not persisted: the pitch carries NoMount volumes and a + // region flight ceiling, and neither survives teardown below. + if (data.liftLimits) { + try { FoundationWorld.policy.restore(); } catch (_) {} + if (typeof data.ceiling === "number") { try { Broom.setMaxHeight(data.ceiling); } catch (_) {} } + } + if (data.unlockCasting) { try { Broom.setCastingAllowed(true); } catch (_) {} } + openBanner(); // warm the overlay now, so the first goal is not the thing that builds it + openScoreboard(); // the server's first push follows seating + + // The hoops, the quaffle, the bludgers and the snitch are server WorldObjects, streamed in like + // any other; this side only watches the quaffle to know when it is in this player's hand. + ballNetId = data.ballId || 0; + if (data.bat) equipBat(data.bat, data.batSocket, data.batOffset); + Game.notify(`Quidditch: ${data.mode} — you are ${data.role} for ${data.team}.`); + nudgeToMount(); +}); + +on("quidditch:unstick", () => { + if (stunTimer) { clearTimeout(stunTimer); stunTimer = null; } + stunHeld = false; + // Pop a few times: the stack takes one entry per push, and a stuck player may have several. + for (let i = 0; i < 5; i++) { try { Broom.setInputDisabled(false); } catch (_) {} } + // PartialBody ONLY. FullBody carries the broom's own pose ability (ABL_HoverBroom_Idle_*), so + // cancelling it strips the rider to an A-pose — which is what the first version of this did. + try { LocalPlayer.cancelAbility("PartialBody"); } catch (_) {} + try { Broom.setForceLand(false); } catch (_) {} + Game.notify("Input restored."); +}); + +on("quidditch:teardown", () => { teardown(); Game.notify("Quidditch: session over."); }); + +// Chat is behind the focused panel, so refusals have to surface where the click happened. +on("quidditch:notice", (data) => { if (data.text) Game.notify(data.text); }); + +// The gate is a GameLogic BoolVar, so it applies to the whole client, not to a broom instance — it +// can be set on the ground and is still in force once mounted. +on("quidditch:testCast", (data) => { + let ok = false; + try { ok = !!Broom.setCastingAllowed(!!data.on); } catch (_) {} + Game.notify(ok + ? `Mounted casting ${data.on ? "unlocked" : "restored"}.` + : "Mounted-cast toggle failed — no GameLogicComponent on the pawn."); +}); + +// The same event serves the release and a respawn (trail tuning, a reset); only one of them is news. +on("quidditch:resnitch", (data) => { + if (session && data.released) Game.notify("The Snitch is released!"); +}); + +// You cannot tell a proximity hold is running by looking at it, so say so. +on("quidditch:catching", (data) => { + if (data.on) Game.notify(`Closing on the Snitch — hold it for ${((data.ms || 1200) / 1000).toFixed(1)}s`); +}); + +on("quidditch:snitchBlip", (data) => { + Game.notify(`Snitch minimap blip ${data.on ? "on" : "off"}.`); +}); + +// The quaffle itself is a server physics object: carrying, throwing and resetting all happen on the +// entity, so this side only plays the throw. +on("quidditch:ballThrow", () => playThrowAnimation()); + +on("quidditch:goal", () => { try { Camera.playShake("", { scale: 0.4 }); } catch (_) {} }); + +/** + * A bludger hit is a partial-body reaction plus a control lock — no damage call, and deliberately + * not DismountBroom, which writes 'ActiveMountItem' into the save. + */ +on("quidditch:knock", (data) => { + const cfg = (session && session.bludgers) || {}; + const ability = cfg.reactionAbility || "/Game/Pawn/Student/Abilities/PartialBody/Broom/ABL_HoverBroom_Hitch_Frnt.ABL_HoverBroom_Hitch_Frnt_C"; + const channel = cfg.reactionChannel || "PartialBody"; + playFor(ability, channel, cfg.reactionCancelMs || 1200); + // Input lock OFF by default. setInputDisabled is a push/pop on the broom flag stack, and a pop + // that does not land leaves a rider permanently unable to steer — which happened, twice, and + // neither the retry nor the unstick freed it. A bludger that staggers you visually and costs you + // the quaffle is worth more than one that risks ending your session. + if ((session && session.bludgers && session.bludgers.stunDisablesInput)) { + stunInput(data.ms || 1800); + } + try { Camera.playShake("", { scale: 0.8 }); } catch (_) {} +}); + +function playThrowAnimation() { + const ability = "/Game/Pawn/Student/Abilities/PartialBody/UseInventoryItem/Potion/ABL_Partial_ThrowInventoryItem.ABL_Partial_ThrowInventoryItem_C"; + playFor(ability, "PartialBody", 1200); +} + +/** + * Play an ability and guarantee its channel is released. + * + * Every ability this resource plays carries m_Length = +inf, so without a paired cancel it parks on + * its channel forever — in shipped play a Behaviour Tree tears them down, and a relayed play has no + * BT. Keyed by channel so two abilities in flight cannot cancel each other's release. + */ +/** + * Take rider input for a moment, and be certain it comes back. + * + * setInputDisabled is a PUSH/POP pair on the broom's flag stack — Start pushes at Debug priority, + * Finish pops — and BOTH ends need MountedBroom() to resolve. Firing the release once and trusting + * it is how a player ends up permanently unable to steer: if the broom is momentarily unresolvable + * when the timer lands, the push simply stays on the stack and nothing ever pops it. + * + * So: at most one push outstanding, and a release that retries until it is actually taken. + */ +let stunHeld = false; +let stunUntil = 0; +let stunTimer = null; + +function stunInput(ms) { + const now = Date.now(); + stunUntil = Math.max(stunUntil, now + ms); + if (!stunHeld) { + try { stunHeld = !!Broom.setInputDisabled(true); } catch (_) { stunHeld = false; } + } + if (stunTimer) clearTimeout(stunTimer); + stunTimer = setTimeout(() => releaseStun(0), Math.max(0, stunUntil - now)); +} + +/** + * Self-heal. The override stack is weak-instigator-keyed and NOT refcounted, so our (Debug, null) + * entry is a single entry and one Finish clears it — the only way to stay locked is for that Finish + * never to be CALLED. This runs while a session is up and pops whenever we believe we should not be + * stunned, which turns 'permanently unable to steer' into 'at most a couple of seconds'. + * + * Popping when nothing is pushed is free: Finish returns false and changes nothing. + */ +setInterval(() => { + if (!session || stunHeld) return; + try { Broom.setInputDisabled(false); } catch (_) {} +}, 2000); + +function releaseStun(tries) { + stunTimer = null; + if (!stunHeld) return; + let ok = false; + try { ok = !!Broom.setInputDisabled(false); } catch (_) {} + if (ok) { stunHeld = false; return; } + // Not mounted yet, or the broom did not resolve. Keep trying — giving up here strands the player. + if (tries < 40) { stunTimer = setTimeout(() => releaseStun(tries + 1), 250); return; } + stunHeld = false; + console.error("[QUIDDITCH] could not restore broom input after 10s — the flag stack may still hold it"); +} + +function playFor(ability, channel, ms) { + let handle = null; + try { handle = LocalPlayer.playAbility(ability, channel); } catch (_) {} + if (!handle) return; + const prev = cancelTimers.get(channel); + if (prev) clearTimeout(prev); + // By handle: ends OUR ability only. A channel-wide cancel here used to hit whatever had replaced it. + cancelTimers.set(channel, setTimeout(() => { + cancelTimers.delete(channel); + try { LocalPlayer.cancelAbilityHandle(handle); } catch (_) {} + }, ms)); +} + +function aimForward() { + const rot = LocalPlayer.getControlRotation(); + if (!rot) return null; + const pitch = (rot.pitch || 0) * Math.PI / 180; + const yaw = (rot.yaw || 0) * Math.PI / 180; + return { x: Math.cos(pitch) * Math.cos(yaw), y: Math.cos(pitch) * Math.sin(yaw), z: Math.sin(pitch) }; +} + +function throwBall(speed) { + const dir = aimForward(); + if (!dir) return; + Events.emitServer("quidditch:throw", JSON.stringify({ x: dir.x, y: dir.y, z: dir.z, speed })); +} + +on("quidditch:aimRequest", (data) => { + const dir = aimForward(); + if (!dir) return; + // A swing carries no speed: the server owns how hard a bludger leaves, we only say where. + if (data.kind === "block") { + Events.emitServer("quidditch:block", JSON.stringify({ x: dir.x, y: dir.y, z: dir.z })); + return; + } + if (data.kind === "swing") { + Events.emitServer("quidditch:swing", JSON.stringify({ x: dir.x, y: dir.y, z: dir.z })); + return; + } + const cfg = (session && session.ball) || {}; + throwBall(data.kind === "shoot" ? (cfg.throwSpeedMax || 4500) : (cfg.throwSpeed || 3000)); +}); + +// Confirmation of a connected swing. Paired cancel, like every playAbility here: a relayed +// ability with m_Length = +inf parks on its channel forever without a Behaviour Tree to end it. +on("quidditch:swung", () => { + const cfg = (session && session.bludgers) || {}; + const ability = cfg.swingAbility + || "/Game/Pawn/Student/Abilities/PartialBody/Conjuring/ABL_ConjuringObject_SwipeRight.ABL_ConjuringObject_SwipeRight_C"; + const channel = cfg.swingChannel || "PartialBody"; + playFor(ability, channel, cfg.swingCancelMs || 900); + try { Camera.playShake("", { scale: 0.3 }); } catch (_) {} +}); + +// Foundations arbitrates all physical keys. Server handlers repeat every range, role and possession +// check, so these callbacks express intent only. +const registerShortcut = (id, key, handler, when = () => !!session) => Hmp.input.shortcuts.register({ + resource: RESOURCE, id, key, state: "down", priority: 40, when, handler, +}); +registerShortcut("pickup", "e", () => Events.emitServer("quidditch:pickup", "{}")); +registerShortcut("pass", "q", () => throwBall((((session || {}).ball || {}).throwSpeed) || 3000), () => !!session && carrying); +registerShortcut("shoot", "r", () => throwBall((((session || {}).ball || {}).throwSpeedMax) || 4500), () => !!session && carrying); +registerShortcut("swing", "v", () => { + const dir = aimForward(); + if (dir) Events.emitServer("quidditch:swing", JSON.stringify(dir)); +}); +registerShortcut("block", "b", () => { + const dir = aimForward(); + if (dir) Events.emitServer("quidditch:block", JSON.stringify(dir)); +}); +registerShortcut("tackle", "t", () => Events.emitServer("quidditch:tackle", "{}")); + +// Pass and shoot are only offered while the quaffle rides this player's hand: an attached object's +// owner is its carrier, so owner-and-attached on this client means the ball is ours. +setInterval(() => { + let state = null; + if (session && ballNetId) { try { state = World.worldObjectState(ballNetId); } catch (_) {} } + carrying = !!(state && state.owner && state.attached); +}, 100); + +// ------------------------------------------------------------------ the beater bat, held + +/** + * The held prop — the beater bat. Cosmetic, so every step is best-effort: a bat that fails to + * appear must never stop a session starting. + * + * equipProp handles static and skeletal assets alike (three of the shipped bats are static shop or + * station meshes, one is the rigged Beater prop), so this only has to pass a path. The offset is + * live because WandSocket is authored for a short wand and a long bat will not sit right in it. + */ +function equipBat(mesh, socket, offset) { + if (!mesh) { dropBat(); return; } + let ok = false; + try { ok = !!LocalPlayer.equipProp(mesh, socket || "WandSocket"); } catch (_) {} + if (!ok) { Game.notify("Bat failed to equip — check the log: the asset may not be a mesh."); return; } + Game.notify("Bat equipped."); + applyBatOffset(offset); + batOn = true; +} + +function applyBatOffset(o) { + const d = o || {}; + try { + LocalPlayer.setPropOffset(d.pitch || 0, d.yaw || 0, d.roll || 0, + d.x || 0, d.y || 0, d.z || 0, d.scale || 1); + } catch (_) {} +} + +function dropBat() { + if (!batOn) return; + try { LocalPlayer.clearProp(); } catch (_) {} + batOn = false; +} + +on("quidditch:bat", (data) => { + if (!data.mesh) { dropBat(); Game.notify("Bat put away."); return; } + equipBat(data.mesh, data.socket, data.offset); +}); + +// ---------------------------------------------------------------- banner overlay + +/** + * Goals and the snitch catch, across the screen. Deliberately NOT focused and never locking: a + * focused CEF view holds the cursor and takes controls, which mid-flight would fly you into the + * stands. showView alone leaves every input with the game, which is the whole requirement here. + */ +const BANNER_URL = "fw://resources/hmp-quidditch/dist/banner.html"; +let bannerView = -1; +let bannerReady = false; +let bannerQueue = []; + +function openBanner() { + if (bannerView >= 0) return; + try { bannerView = Web.createView(BANNER_URL); } catch (_) { bannerView = -1; } + if (bannerView < 0) return; // web layer not up yet; the next banner retries + // The page can miss an event fired before its script ran — on a cold boot the HTML is still + // syncing down — so it announces itself and we flush what was queued meanwhile. + Web.on(bannerView, "quid:bannerReady", () => { + bannerReady = true; + for (const b of bannerQueue.splice(0)) { try { Web.emit(bannerView, "quid:banner", b); } catch (_) {} } + }); + try { Web.showView(bannerView); } catch (_) {} +} + +// A plain destroy is safe: the SERVER holds the session open for `scoring.celebrateMs` after the +// thing worth announcing, so teardown no longer races the banner it just fired. +function closeBanner() { + bannerReady = false; + bannerQueue = []; + if (bannerView < 0) return; + try { Web.hideView(bannerView); Web.destroyView(bannerView); } catch (_) {} + bannerView = -1; +} + +on("quidditch:banner", (data) => { + openBanner(); + if (bannerView < 0 || !bannerReady) { + bannerQueue.push(data); + if (bannerQueue.length > 4) bannerQueue.shift(); + return; + } + try { Web.emit(bannerView, "quid:banner", data); } catch (_) {} +}); + +// ---------------------------------------------------------------- scoreboard overlay + +// Same contract as the banner: shown, never focused. It is up for the whole session rather than +// for a moment, which makes the focus rule matter more here, not less. +const SCORE_URL = "fw://resources/hmp-quidditch/dist/scoreboard.html"; +let scoreView = -1; +let scoreReady = false; +let scoreLast = null; // replayed on ready: the view can outlive its first push on a cold boot + +function openScoreboard() { + if (scoreView >= 0) return; + try { scoreView = Web.createView(SCORE_URL); } catch (_) { scoreView = -1; } + if (scoreView < 0) return; + Web.on(scoreView, "quid:scoreReady", () => { + scoreReady = true; + if (scoreLast) { try { Web.emit(scoreView, "quid:score", scoreLast); } catch (_) {} } + }); + try { Web.showView(scoreView); } catch (_) {} +} + +function closeScoreboard() { + scoreReady = false; + scoreLast = null; + if (scoreView < 0) return; + try { Web.hideView(scoreView); Web.destroyView(scoreView); } catch (_) {} + scoreView = -1; +} + +on("quidditch:scoreboard", (data) => { + openScoreboard(); + scoreLast = data; + if (scoreView < 0 || !scoreReady) return; // the ready handshake replays it + try { Web.emit(scoreView, "quid:score", data); } catch (_) {} +}); + +// ---------------------------------------------------------------- results panel + +// Same rules as the banner: shown, never focused. It reads out and fades — nothing to click, so +// nothing that could strand the player behind a locked cursor. +const RESULTS_URL = "fw://resources/hmp-quidditch/dist/results.html"; +let resultsView = -1; +let resultsReady = false; +let resultsPending = null; + +function closeResults() { + resultsReady = false; + resultsPending = null; + if (resultsView < 0) return; + try { Web.hideView(resultsView); Web.destroyView(resultsView); } catch (_) {} + resultsView = -1; +} + +on("quidditch:resultsClose", () => closeResults()); + +on("quidditch:results", (data) => { + if (resultsView < 0) { + try { resultsView = Web.createView(RESULTS_URL); } catch (_) { resultsView = -1; } + if (resultsView < 0) return; + Web.on(resultsView, "quid:resultsReady", () => { + resultsReady = true; + if (resultsPending) { try { Web.emit(resultsView, "quid:results", resultsPending); } catch (_) {} } + resultsPending = null; + }); + try { Web.showView(resultsView); } catch (_) {} + } + if (!resultsReady) { resultsPending = data; return; } + try { Web.emit(resultsView, "quid:results", data); } catch (_) {} +}); + +// ---------------------------------------------------------------- match call card + +// A called match, as a small card on the right. Shown, never focused: everyone eligible gets one, +// and somebody who does not want to play must be able to carry on as if it were not there. The key +// is only bound while a call is up, and only it opens the (focused) picker. +const LOBBY_URL = "fw://resources/hmp-quidditch/dist/lobby.html"; +let lobbyView = -1; +let lobbyReady = false; +let lobbyLast = null; +let lobbyKey = null; // unregister for the join shortcut, bound while a card is up + +function closeLobbyCard() { + lobbyReady = false; + lobbyLast = null; + if (lobbyKey) { try { lobbyKey(); } catch (_) {} lobbyKey = null; } + if (lobbyView < 0) return; + try { Web.hideView(lobbyView); Web.destroyView(lobbyView); } catch (_) {} + lobbyView = -1; +} + +on("quidditch:lobbyCard", (data) => { + if (data.closed) { closeLobbyCard(); return; } + lobbyLast = data; + if (lobbyView < 0) { + try { lobbyView = Web.createView(LOBBY_URL); } catch (_) { lobbyView = -1; } + if (lobbyView < 0) return; + Web.on(lobbyView, "quid:lobbyReady", () => { + lobbyReady = true; + if (lobbyLast) { try { Web.emit(lobbyView, "quid:lobby", lobbyLast); } catch (_) {} } + }); + try { Web.showView(lobbyView); } catch (_) {} + } + if (!lobbyKey) { + lobbyKey = Hmp.input.shortcuts.register({ + resource: RESOURCE, id: "lobby", key: String(data.key || "J").toLowerCase(), state: "down", priority: 40, + when: () => lobbyView >= 0, + handler: () => Events.emitServer("quidditch:lobbyOpen", "{}"), + }); + } + if (lobbyReady) { try { Web.emit(lobbyView, "quid:lobby", data); } catch (_) {} } +}); + +/** + * Disconnecting never reaches quidditch:teardown — that is a SERVER event, and a client that has + * gone does not receive one. resourceStop fires locally on disconnect, hot reload and shutdown alike. + * Every pitch object is a server WorldObject now, so nothing in this world outlives the session. + */ +Events.on("resourceStop", (name) => { + if (name && name !== RESOURCE) return; + try { teardown(); } catch (_) {} + try { closeLobbyCard(); } catch (_) {} + try { Hmp.input.cleanup(RESOURCE); } catch (_) {} +}); + +logger.info("Client ready"); diff --git a/gamemodes/resources/hmp-quidditch/client/results.html b/gamemodes/resources/hmp-quidditch/client/results.html new file mode 100644 index 0000000..3d3f496 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/client/results.html @@ -0,0 +1,135 @@ + +Quidditch Results + + +
+ + diff --git a/gamemodes/resources/hmp-quidditch/client/scoreboard.html b/gamemodes/resources/hmp-quidditch/client/scoreboard.html new file mode 100644 index 0000000..857ed3b --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/client/scoreboard.html @@ -0,0 +1,132 @@ + +Quidditch Scoreboard + + +
+
0
+
--:--
+ +
+ + diff --git a/gamemodes/resources/hmp-quidditch/package.json b/gamemodes/resources/hmp-quidditch/package.json new file mode 100644 index 0000000..eb1a243 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/package.json @@ -0,0 +1,37 @@ +{ + "name": "hmp-quidditch", + "version": "0.8.2", + "private": true, + "author": "HogwartsMP", + "description": "Server-authoritative Quidditch built on the HMP Foundations stack.", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/server.js", + "scripts": { + "build": "node scripts/build.mjs", + "test": "node test/foundations.test.js && node test/server.test.js", + "smoke": "node test/bundle.test.js" + }, + "devDependencies": { + "esbuild": "0.25.12" + }, + "mafiahub": { + "clientScripts": ["dist/client.js"], + "serverScripts": ["dist/server.js"], + "files": ["dist/client.js", "dist/*.html"], + "exports": ["status", "call", "start", "stop"], + "resourceDependencies": [ + { "name": "hmp-lib", "version": "0.8.2" }, + { "name": "hmp-core", "version": "0.8.2" }, + { "name": "hmp-activities", "version": "0.8.2" }, + { "name": "hmp-ui", "version": "0.8.2" }, + { "name": "hmp-houses", "version": "0.8.2" }, + { "name": "hmp-inventory", "version": "0.8.2" }, + { "name": "hmp-interact", "version": "0.8.2" }, + { "name": "hmp-audio", "version": "0.8.2" }, + { "name": "hmp-blips", "version": "0.8.2" }, + { "name": "hmp-world", "version": "0.8.2" } + ], + "errorBehavior": "stop", + "priority": -300 + } +} diff --git a/gamemodes/resources/hmp-quidditch/quidditch.config.js b/gamemodes/resources/hmp-quidditch/quidditch.config.js new file mode 100644 index 0000000..b7a59fb --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/quidditch.config.js @@ -0,0 +1,513 @@ +/** + * quidditch — declarative config. Coordinates are world centimetres. + * + * The pitch numbers come from the game's own data, not from pacing it out: the POI + * `HW_QuidditchPitch` in PhoenixGameData.sqlite, and the streaming tile that owns the pitch + * collision. Both matter — see `envelope` below. + */ +module.exports = { + version: 1, + + pitch: { + // DEV_PointsOfInterest.HW_QuidditchPitch. Everything else is expressed relative to this. + centre: { x: 323482.0, y: -465966.0, z: -85353.9 }, + + // The POI is NOT the ground — it sits ~2 m above it. The level's own geometry clusters hard + // at Z=-85550 within 60 m of centre, which is the deck, and that independently matches the + // collision-plane height the streaming research landed on. Hoop heights are measured from + // here, not from `centre`, so they mean what they say. + deckOffset: -200, + + // The pitch geometry lives in the streamed tile HN_AZ_Quidditch, whose box is roughly + // 516 x 588 m horizontally but only ~60 m tall above the POI. Leaving it does not just + // unload scenery — the floor's collision unregisters on *hide*, before unload. Keep play + // inside this and a dropped ball still has something to land on. + envelope: { xy: 25000, up: 5500, down: 1500 }, + + // cm ABOVE THE DECK that a player is placed at kickoff. Zero, because there is no way to + // put someone on a broom from script: the local mount runs through ABroomItemTool -> + // AFlyingBroom.PlayerMountAbility and nothing we expose reaches it. Spawning in the air + // therefore means falling, and an eleven-metre drop onto the deck is how it used to start. + // Standing on the pitch and kicking off is also what a real match looks like. + // + // Raise this once mounting is drivable, not before. + spawnHeight: 60, + + // Nearest fast travel is M_FT_BRR_01 at ~55 m; FT_HW_FlyingClass at ~196 m is the safer + // public one because BRR_01's is a race pad. + fastTravel: "FT_HW_FlyingClass", + + // Hoop placement is derived, not hand-typed. Measured from the level itself + // (HN_AZ_Quidditch, 338 structure points): the pitch's long axis runs 82.8 deg from +X — + // i.e. essentially along +Y — and the structure spans 189 m along it by 112 m across. The + // level's bounding-box centre lands within 15 m of the POI above, so `centre` is the field + // centre. Stands are included in those spans, so the playable field is smaller. + hoopGeometry: { + axisDeg: 82.8, // long axis of the pitch, degrees from +X + goalDistance: 5000, // cm from centre to each goal line, ALONG the axis + spacing: 1100, // cm between adjacent hoops, ACROSS the axis + // cm ABOVE THE DECK, in lateral order: left, centre, right. Symmetric, centre tallest. + // The hoops are not in the level, so unlike everything else here these were not measured + // — they were flown and eyeballed in game, which is why they sit much higher than the + // first guess at them. + heights: [2150, 3000, 2150], + // Ring yaw = axisDeg + this. A race ring's aperture faces its forward (local +X), and + // a hoop must be flown/shot THROUGH along the pitch's length. If the rings end up + // side-on, the ring's aperture is perpendicular to its forward — add 90 here. + yawOffset: 0, + }, + + // Whose end is whose, readable from the air. A player mid-flight should not have to work out + // which triple of hoops is theirs by dead reckoning off the pitch axis — the banner says it. + goalBanners: { + gryffindor: "/Game/Environment/Hogwarts/Meshes/Props/SM_HW_Banner_Gryf_A.SM_HW_Banner_Gryf_A", + slytherin: "/Game/Environment/Hogwarts/Meshes/Props/SM_HW_Banner_Sly_A.SM_HW_Banner_Sly_A", + hufflepuff: "/Game/Environment/Hogwarts/Meshes/Props/SM_HW_Banner_Huff_A.SM_HW_Banner_Huff_A", + ravenclaw: "/Game/Environment/Hogwarts/Meshes/Props/SM_HW_Banner_Rav_A.SM_HW_Banner_Rav_A", + }, + // ABOVE the goal, not behind it. Two measured facts decide this: + // + // - the mesh HANGS DOWN from its pivot (ExtendedBounds origin Z -284, extent 312, so it + // reaches ~600 units below and only ~28 above), so the spawn point is the top of the + // banner, not its middle; + // - pushing it 9 m past the goal line put it inside the stands, where it rendered perfectly + // and was seen by nobody. + // + // So: hang it from high enough that its bottom clears the tallest hoop. + // Offsets in the BANNER's own frame: `fwd` runs down the pitch, `side` is its own right. + // Both mirror at the far end, so one pair of numbers places the banner at both goals. + bannerFwd: 0, + bannerSide: -225, + bannerLift: 550, // gap between the banner's bottom edge and the top hoop + bannerDropUnits: 600, // how far the mesh falls below its own pivot, unscaled — measured + bannerScale: 3, + // A banner is a flat plane, so its face is a quarter turn off the axis it hangs along. + // WHICH quarter was measured by spawning all four side by side and seeing which faced + // back. It read 90 when the big banner took its yaw from the pitch axis; both sizes now + // take the ring's own facing plus the per-end flip, and against that basis it is 270. + bannerYawOffset: 270, + + // A smaller banner under every ring, not just the big one over the goal. Three narrow + // pennants under three hoops reads as ownership at a glance from any angle, where one large + // banner only reads from in front of the centre. + // + // Sized to hang clear: a ring aperture is 383 across and the mesh is ~180 wide unscaled, so + // 1.5 keeps it inside the ring's width. The drop is measured from the ring down, and the + // lowest hoop sits 2150 above the deck — enough for a 900-long hang plus the gap. + bannerPerHoop: true, + bannerHoopScale: 1.5, + bannerHoopGap: 480, + // The ring banners carry their own yaw and offsets. They hang under a ring rather than + // over the goal, so nothing says they want the big one's numbers. + bannerHoopYaw: 270, + bannerHoopSide: 10, + bannerHoopFwd: 0, + + // A hoop is a race ring, and a race ring carries three hidden BlockAll pillars ~11 m below + // its aperture. Anything below this height above the deck puts an invisible wall on the pitch. + hoopMinHeight: 1300, + }, + + ball: { + mesh: "/Game/Gameplay/Nurturing/Creatures/Meshes/Toys/SM_Sanc_BeastToy_QuidditchQuaffle.SM_Sanc_BeastToy_QuidditchQuaffle", + massKg: 2.0, + // How close you must be for "Take Quaffle" to be offered — a server distance check, since + // the simulating body ignores Pawn and so cannot see brooms. Not a ~1 m reach: that is + // nothing at all on a broom, which crosses it inside one tick before the offer appears. + // + // Sized against SPEED, not against arm's length. A broom covers ~20 m/s, so even with the + // context poke refreshing the offer at ~6 Hz you move ~3 m between rebuilds; at 7 m the + // window was under half a second and the press landed after it closed. 12 m is about a + // second of approach, which is what makes it pressable rather than lucky. + // Who may take possession. A Chaser's job is the quaffle, and the Keeper handles it too -- + // canon has them gathering it to put it back in play, and a Keeper who could clear a shot + // but never pick the ball up was left with nothing to do with a save. + // + // Carrying is the whole permission: throwing, passing and shooting all gate on BEING the + // carrier rather than on a role, so this one list decides who can do any of it. A Seeker + // who happens to fly past should not end up carrying the match. Empty or absent = anyone. + carryRoles: ["chaser", "keeper"], + pickupRadius: 1200, + // The only socket on this skeleton proven to exist — the wand rides it. A name that does not + // exist does not error: the engine attaches at the mesh origin instead, silently. + carrySocket: "WandSocket", + + // Keys for the carry actions. Both aim along the camera, so they have to be a single press — + // opening a radial menu to shoot means letting go of the mouse, which IS the aim. rp-ui binds + // these only while the action is legal, so they cost nothing when you are not carrying. + // `f` is contested by five other resources and `i`/`e` are taken; q and r are free. + passKey: "q", + shootKey: "r", + + // Above ~4500 a 20 Hz segment test starts to matter; above ~5000 physics itself gets + // unreliable, since this build has no sub-stepping (MaxPhysicsDeltaTime 0.0333). + throwSpeed: 3000, + throwSpeedMax: 4500, + gravity: 980, + }, + + snitch: { + // Seeker airspace, relative to centre. Kept well inside `envelope`. + volume: { xy: 9000, minZ: 800, maxZ: 4800 }, + // The shipped BP flies itself and is client-local + random-driven, so the server owns the + // authoritative point and clients run the actor as a puppet. + // A leg is a short hop in a mostly-continuous direction, NOT a jump to a random point in + // the volume — that reads as teleporting and cannot be chased. Distance per leg is + // speed * legSeconds, so these three numbers are what make it feel like a pursuit. + // The BP's OWN timeline play-rate (its default is 3.0), scaled by fatigue every steer beat. + // Without this only the container box slows down and the snitch keeps flying full-speed arcs + // around a stationary point — the box says WHERE it roams, this says HOW FAST it flies. + // Never quite 0: a stalled timeline may not fire the callback that starts the next state. + // A leg is a LERP over a 1 s timeline, so its duration is 1/rate no matter how far it has to + // go. That makes the floor load-bearing: at 0.05 a stranded mesh took ~20 s to reach its box + // and freeze/"bring to me" could not recover it, so the snitch you saw and the snitch being + // tested ended up tens of metres apart. At 1.0 a leg always lands within a second. + bpSpeed: 3.0, + bpSpeedMin: 1.0, + + // How FAR a Swoop may carry it: the steering container's box extent in cm, scaled by fatigue. + // This is the one that actually stops an exhausted snitch. bpSpeed alone only slows the + // timeline, so a tired snitch flew the same long leg slowly and still crossed the pitch. + // Flit and Vibrate roam boxes attached to the snitch itself, so it always keeps a small + // local flutter — which is what an exhausted snitch should look like anyway. + roam: 3000, + roamMin: 60, + + legSeconds: 1.8, + cruiseSpeed: 1100, // cm/s when nobody is close + fleeSpeed: 2100, // cm/s once a seeker is inside fleeRadius + momentum: 0.7, // how much of the previous heading carries into the next leg + wander: 0.55, // random turn mixed in per leg + fleeRadius: 2200, + fleeStrength: 0.9, // weight of the away-from-seeker term when fleeing + // Caught by FLYING CLOSE AND STAYING THERE — not by Accio, not by collision. The radius no + // longer has to absorb puppet-vs-server drift (the catch tests the position the client + // reports for its own actor), so it can be tight enough that staying on the snitch is the + // skill rather than being roughly near it. + // Who may take the snitch. The `snitchHunt` mode ignores this on purpose -- everyone + // hunting is the whole game there. Empty or absent = anyone. + catchRoles: ["seeker"], + catchRadius: 600, + steerMs: 200, // how often the simulator's steering box is re-aimed at the live point + catchHoldMs: 2000, + feintChance: 0.18, + feintHideMs: 3000, + points: 150, + + // A snitch that never tires is a stalemate — it can always outrun you, so the hunt only ends + // when the match clock does. It wears down instead: after `graceSec` its speed and evasion + // decay toward `floor`, and the catch window widens, so the chase always converges. + // Fully tired at graceSec + rampSec. `floor` is how tired, not how soon. + // Seconds after kickoff before the snitch is let out. Canon releases it after a delay, and + // the practical reason is the same: with it on the pitch from the first second a 15-minute + // match can be over in ninety seconds, and everything else on the pitch never happens. + // + // The fatigue clock below starts at RELEASE, not at kickoff. Timing it from kickoff would + // spend the grace period while the snitch was still in the box. + // + // 0 releases it immediately. Snitch Hunt sets that on the mode, because there the snitch is + // the entire game. + releaseAfterSec: 300, + + fatigue: { + graceSec: 45, // full strength for this long, nothing changes + rampSec: 180, // then it slows over this many seconds + // 0 = it stops dead when fully spent, which is the strongest version of "the chase always + // converges": there is a moment where the snitch is simply there to be taken. Raise it if + // an exhausted snitch reads as broken rather than beaten. + floor: 0, + radiusBonus: 700, // extra catch radius once fully tired (cm) + }, + + // No world label: the Niagara comet trail tracks it well enough by eye, and the label was + // competing with the single world-prompt slot for no benefit. + + // Minimap blip OFF by default. A seeker with a blip plays the minimap instead of the sky, + // which is the opposite of the point — the trail exists so the snitch can be found by eye. + // Toggleable live from the manager, since whether the hunt is fair without it is a feel + // question and not one to settle from a config file. + blip: false, + // The shipped snitch prop is only a few centimetres across. Scale it until it reads against + // sky; 1 is the authentic size and is genuinely hard to spot. + scale: 4.0, + // On/off only — the Niagara spawn signature has no Scale parameter. NOT the game's own + // VFX_P_SnitchWingTrail: that is a Cascade AnimTrail needing two skeletal sockets via + // BeginTrails, unreachable through SpawnEmitterAttached, and a 14 cm wing blur regardless. + trailScale: 1, + // Niagara with no EffectType, so nothing distance-culls it. + trail: "/Game/VFX/Niagara/Interactables/VFX_NS_FlyingSparkleComet_Trail.VFX_NS_FlyingSparkleComet_Trail", + hudRange: 100, // metres over which the proximity bar closes + }, + + bludgers: { + // Bludgers are SERVER-simulated WorldObjects: the server moves them and every client in + // range interpolates. They used to be gated to `match` (2 players minimum), so they had never actually been + // seen in play; the count is the switch now and /quidditch bludgers overrides it live. + count: 2, + // ON during bring-up. The purist argument is the snitch's — a thing you track on the minimap + // is a thing you never look up at — but that only holds once you can reliably SEE the thing, + // and a bludger that hits you from off-screen with no marker is just a mystery. Turn this off + // once the mesh and its trail read well in the air. + blip: true, + // Bludgers WIND UP rather than cruising. A constant-speed homer is either always too fast to + // dodge or always too slow to fear; ramping from speedMin toward speedMax gives it a menace + // that builds, and every hit or bat drops it back to speedMin so the threat has to rebuild. + speedMin: 750, + speedMax: 2200, + accel: 500, // cm/s^2 + // The shipped bludger mesh is small for something meant to be seen coming at speed. + scale: 4.0, + // The REAL Quidditch bludger. BP_BludgerBallToy spawns happily and draws nothing (a + // Vivarium toy is dressed when a beast picks it up). Its skeleton, Beater/SK_Quidditch, is + // the BLUDGER rig, not a bat: VFX_Impact, VFX_Trail_Base_Socket and VFX_Dust_Socket. + mesh: "/Game/RiggedObjects/Props/Quidditch/Bludger/SK_Quid_Bludger.SK_Quid_Bludger", + // The game's own bludger trail, from the "Don't Snitch" world event. + trail: "/Game/VFX/World_Events/WOW/WOW_DontSnitch/VFX_NS_BludgerTrail.VFX_NS_BludgerTrail", + trailSocket: "VFX_Trail_Base_Socket", + homingStrength: 0.35, + hitRadius: 80, + // A hit is a knock, not damage: a partial-body reaction that cannot dismount, plus a short + // input lock. Nothing here touches health or the save. + // How long input is taken. Short on purpose: a bludger should stagger you, not bench you. + stunMs: 5000, + // How long you cannot be hit again — a SEPARATE and much longer window. These used to be one + // number, so immunity expired the moment control returned and two bludgers could chain you + // almost continuously: stunned 1.8 s out of every 4, which is indistinguishable from stuck. + hitImmuneMs: 15000, + // Back ON, with a watchdog behind it. The override stack is weak-instigator-keyed and NOT + // refcounted: our (Debug, null) push is one entry and a single Finish clears it, so the only + // way to stay locked is for that Finish never to be called. The client pops every 2 s while + // it believes it should not be stunned, which bounds the worst case at a couple of seconds + // instead of the rest of the session. /quidditch unstick is still the manual way out. + stunDisablesInput: true, + // After a hit it leaves, instead of steering straight back. Without this it orbits whoever it + // just hit at full speed for the whole stun window, landing no further hits and reading as a + // glitch rather than a bludger. + retreatMs: 1500, + retreatSpeed: 1800, + reactionAbility: "/Game/Pawn/Student/Abilities/PartialBody/Broom/ABL_HoverBroom_Hitch_Frnt.ABL_HoverBroom_Hitch_Frnt_C", + reactionChannel: "PartialBody", + // Both Quidditch-usable abilities carry m_Length = +inf and park on their channel forever; + // the Behaviour Tree normally ends them and a relayed play has no BT. + reactionCancelMs: 1200, + + // --- the beater swing --- + // AFlyingBroom declares SwingBeaterAbility and BeaterMeshComponent, but those ability assets + // were STRIPPED from the shipped game, and there is no player-skeleton swing anywhere in it. + // The creature swings (Troll, MerlinGrunt) are the wrong rig entirely. This conjuring swipe is + // a lateral arm sweep on the player, partial-body, and reads as a bat swing. + swingAbility: "/Game/Pawn/Student/Abilities/PartialBody/Conjuring/ABL_ConjuringObject_SwipeRight.ABL_ConjuringObject_SwipeRight_C", + swingChannel: "PartialBody", + swingCancelMs: 900, + swingKey: "v", // e/q/r are taken by take/pass/shoot, f is contested by five resources + // Beaters bat. Everyone used to be able to, on the grounds that a chaser defending itself + // performs the same motion -- but that made the Beater a costume rather than a position. + // Empty or absent = anyone. + swingRoles: ["beater"], + swingRadius: 1400, // how close a bludger must be to swing at, cm + swingSpeed: 2600, // cm/s the bludger leaves at, along your aim + swingImmuneMs: 1200, // it cannot hit the person who just hit it + swingCooldownMs: 700, + swingHit: "bludger_hit_Play", + + // Purely cosmetic. Canon Quidditch has ONE beater bat — a short wooden club, the same for + // both Beaters on a side — and no variant carries a role or a stat. Equipment that actually + // differs in the books is the BROOM, which is where per-role tuning belongs (see Q6). + // Every bat the game ships, pickable live from the manager — the three static ones are shop + // and station dressing, the rigged one is the only asset in a folder actually named Beater. + // equipProp reads the asset to decide StaticMeshActor vs SkeletalMeshActor, so kind is not + // something this list has to declare. + bats: [ + { id: "heavy", label: "Station", mesh: "/Game/Gameplay/Stations/ChildProps/Hogwarts/Quidditch/SM_HM_Quid_HeavyBat.SM_HM_Quid_HeavyBat" }, + { id: "shopA", label: "Shop A", mesh: "/Game/Environment/Hogsmeade/Common/Meshes/MiscProps/SM_HM_BeatersBat_A.SM_HM_BeatersBat_A" }, + { id: "shopB", label: "Shop B", mesh: "/Game/Environment/Hogsmeade/Common/Meshes/MiscProps/SM_HM_BeatersBat_B.SM_HM_BeatersBat_B" }, + { id: "shopC", label: "Shop C", mesh: "/Game/Environment/Hogsmeade/Common/Meshes/MiscProps/SM_HM_BeatersBat_C.SM_HM_BeatersBat_C" }, + // NOT here: RiggedObjects/Props/Quidditch/Beater/SK_Quidditch. It looks like the beater bat + // and is not a mesh at all — it is a **Skeleton** asset (8 sockets: VFX_Impact, + // VFX_Trail_Base_Socket, VFX_Dust_Socket…, plus a Skeleton export). The rig shipped; the mesh + // it posed did not, so there is nothing to display and both mesh loads fail. + ], + bat: "heavy", // which of the above a beater starts with + batSocket: "WandSocket", // the only socket on this skeleton we have proof exists + // Relative to the socket. WandSocket is authored for a short wand, so a long bat will almost + // certainly need nudging — these are live from the manager rather than a rebuild per guess. + // Pitch 90 found by eye in game. The station bat's collision hull runs X from -8 to +57 cm, + // i.e. it lies along its own +X with the grip at the origin — and WandSocket is authored to + // point a short wand forward, so the bat needs standing up out of that axis. + batOffset: { pitch: 90, yaw: 0, roll: 0, x: 0, y: 0, z: 0, scale: 1 }, + }, + + scoring: { + goal: 10, + // Goals are a segment-vs-plane crossing on positions the server already owns. A naive point + // test at 20 Hz silently drops goals above 3540 cm/s; the segment test errs by ~0.3 cm. + tickHz: 20, + hoopRadius: 383, + matchSeconds: 900, + + // The post-match window. The session does NOT end the instant the snitch is caught: the + // pitch stays up, scoring stops, the banner plays and the results panel reads out. Ending in + // the same tick that announced the catch meant the banner and the teardown raced each other. + celebrateMs: 14000, + }, + + /** + * Wwise event names. The game ships Quidditch audio despite the sport being cut — the goal and + * victory stingers belong to a decorative Room-of-Requirement Quidditch table, and the stadium + * ambience to the pitch itself. A sound is just a string: the asset name under + * `/Game/WWise/Events/` IS the event name, and a post whose bank is not resident loads that bank + * and retries by itself, so nothing here needs the pitch to be streamed in first. + * + * Set any of these to "" to silence it. + */ + sounds: { + // A crowd under a firework, on both moments — the crowd sells who it was for, the firework + // sells how big it was. The Quidditch-branded stingers are NOT used: they are inaudible even + // played straight to yourself with /sfxme, being authored for a small Room-of-Requirement + // table ornament rather than a stadium. The `_2D` crowd variants are unpositioned and so play + // at full level wherever on the pitch the goal happened. + goalCheer: "small_crowd_big_cheer_2D", // heard by the scoring side + goalGroan: "small_crowd_dissapointed_2D", // heard by everyone else (asset name is misspelled) + goalNeutral: "small_crowd_lowkey_cheer_Play", // nobody's goal — solo play, or no known scorer + // The lighter volley for a goal; the fuller end-gate below for the thing that ends the match. + goalSting: "BRR_Fireworks_All_Play", + // The moment the match turns: a crowd reacting to something being let out of a box. + snitchRelease: "small_crowd_big_cheer_2D", + victory: "small_crowd_big_cheer_2D", + // What `BP_RaceEndGate_C` posts as you cross a race finish, so it already reads as an ending. + // Fired whenever a session is DECIDED — the snitch or the clock. + finish: "BRR_Fireworks_EndGate_Play", + // Spatialized at the ball, so a pass you did not see still reads as a pass. + catch: "TransfigObs_BeastToybox_ToyBounce_QuidditchQuaffle_Play", + // 2D stadium beds, started on setup and stopped on teardown. Both are loops, so they are the + // one thing here that MUST be stopped — see the id bookkeeping in the server script. + ambienceField: "Amb2D_Overland_QuidditchStadium_Field_Play", + ambienceStands: "Amb2D_Overland_QuidditchStadium_Stands_Play", + }, + + /** + * The keeper, and the scoring area. + * + * A chaser flying the quaffle straight through a hoop leaves nothing to defend. The fix is NOT a + * minimum shot distance — neither canon Quidditch nor real quadball has one. The actual rule is + * that the carrier may not take the ball through the hoop themselves (canon calls the family of + * fouls around this Haversacking), and that defenders may put hands on a carrier in the scoring + * area. So: carrying it through scores nothing, and a keeper can tackle a carrier who tries. + */ + keeper: { + // Around each hoop. Inside it a carrier is tackleable, which is what makes camping the hoop + // a risk rather than a free goal. + scoringArea: 2600, + + // A save: deflect a loose ball along the aim. A clearing kick, not a catch. + // The Keeper's punch-clear. A Chaser standing in the same place has the better option + // already -- pickup reaches 1200 to this 1000, so the ball they could INTERCEPT was also + // offered to them as something to swat away, and only one of those two is ever what a + // Chaser wants. Empty or absent = anyone. + blockRoles: ["keeper"], + blockRadius: 1000, + blockSpeed: 2400, + blockKey: "b", + blockCooldownMs: 800, + blockSound: "bludger_hit_Play", + + // The tackle. Only against a carrier, only inside the scoring area. + tackleKey: "t", + // The Keeper's own answer to a chaser sitting on the hoops. Same shape as ball.carryRoles + // and bludgers.swingRoles: empty or absent = anyone. + tackleRoles: ["keeper"], + tackleRadius: 700, + tackleCooldownMs: 2000, + // The lasting part. Losing the ball is a moment; being unable to take it back is a + // consequence — it forces the attack to reset and rotate rather than the same chaser + // immediately re-grabbing and trying the identical thing. + fumbleMs: 12000, + tackleStunMs: 700, + + // NOT canon and off by default. Kept because it is one number and it is the bluntest way to + // force shots to have flight time, if the tackle turns out not to be deterrent enough. + minShotDistance: 0, + }, + /** + * The lobby a referee calls before a match. + * + * A match needs sides and positions agreed BEFORE it starts, and the old path just swept up + * whoever happened to be connected and assigned roles round-robin — which is fine for testing + * alone and meaningless with people. Here a referee calls it, everyone picks an open slot on + * either side, and the referee starts when the teams look right rather than when a clock says so. + */ + lobby: { + // How long a seat is held for someone who dropped out of a running match. Long enough to + // relaunch the game after a crash; short enough that a slot is not held all match by + // somebody who left for good. + rejoinSeconds: 300, + + // The join window. It is a default, not a rule: the referee can start early at any point, + // and a full lineup does not have to wait out the clock. + joinSeconds: 120, + // Both sides are visible to everyone. Seeing the other team fill up is most of what makes a + // lobby feel like a fixture rather than a form. + teams: ["gryffindor", "slytherin"], + // Canon lineup: three chasers, two beaters, a keeper and a seeker. Seven a side. + lineup: { chaser: 3, beater: 2, keeper: 1, seeker: 1 }, + // Below this the referee gets a warning rather than a refusal — a four-a-side scratch match + // is still a match, and refusing to start one helps nobody. + wantPerTeam: 2, + // A call shows eligible players a small card on the right and nothing else; this key opens the + // position picker for those who want in. + joinKey: "j", + // How often the panel re-reads the roster. Cheap: rp-ui does not re-send a tree that + // serialises to what the client already holds, so a lobby where nothing moved costs nothing. + refreshMs: 900, + panelId: "quidditch:lobby", + }, + + // Crest colours, sent to the overlays rather than duplicated inside each of them. The values + // match banner.html's own map, which still carries a copy for its text variants -- folding + // that one in here as well is on the list. + houseColours: { + gryffindor: "#c0392b", + slytherin: "#2f8f5b", + hufflepuff: "#e8b62c", + ravenclaw: "#4a6cc4", + }, + + roles: ["chaser", "beater", "keeper", "seeker"], + + // Who may call, start, stop and tune a match (the manager, /quidditch call|start|stop|...). + // "staff" means holders of the hmp-core `group` at `minimumGrade` or above; "anyone" opens it to + // every player. Seats, teams and positions are never gated. + control: { + who: "staff", + group: "admin", + minimumGrade: 1, + // With nobody online who qualifies, let anyone run a match rather than leave the pitch locked. + anyoneWhenNoStaff: true, + }, + + modes: { + // Each is playable alone, and each is a real mode: `keeperDuel` used to sit here as a + // label with no logic of any kind behind it -- Full Match under another name -- and + // `hoopCircuit` promised a timed circuit it never had. + // + // `minPlayers` is the only key here that is read. Anything else added to a mode has to be + // wired to something, or it is decoration that reads as a feature. + // `releaseAfterSec` overrides snitch.releaseAfterSec: waiting five minutes for the only + // thing in the mode would be a strange game. + snitchHunt: { label: "Snitch Hunt", minPlayers: 1, releaseAfterSec: 0 }, + // A quiet pitch: hoops and a quaffle, no bludgers and no snitch. Named for what it is, + // which is where you test anything to do with the hoops without something chasing you. + practice: { label: "Practice", minPlayers: 1 }, + match: { label: "Full Match", minPlayers: 2 }, + }, + + // Casting while broom-mounted is off in the stock game (only Revelio). Every spell-based mode + // needs this; it is a per-client GameLogic BoolVar, not a save write. + unlockMountedCasting: true, + + // The pitch has NoMount volumes and a region flight ceiling. Both are lifted per-client for the + // duration of a match and restored after. + liftFlightLimits: true, + + broomItem: "BroomHouse", +}; diff --git a/gamemodes/resources/hmp-quidditch/scripts/build.mjs b/gamemodes/resources/hmp-quidditch/scripts/build.mjs new file mode 100644 index 0000000..746002b --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/scripts/build.mjs @@ -0,0 +1,25 @@ +import { cp, mkdir } from "node:fs/promises"; +import { build } from "esbuild"; + +await mkdir("dist", { recursive: true }); +await build({ + entryPoints: ["server/main.js"], + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + outfile: "dist/server.js", + legalComments: "inline", +}); +await build({ + entryPoints: ["client/main.js"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/client.js", + legalComments: "inline", +}); +for (const name of ["banner.html", "scoreboard.html", "results.html", "lobby.html"]) { + await cp(`client/${name}`, `dist/${name}`); +} diff --git a/gamemodes/resources/hmp-quidditch/server/main.js b/gamemodes/resources/hmp-quidditch/server/main.js new file mode 100644 index 0000000..2311ef4 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/server/main.js @@ -0,0 +1,3097 @@ +/** + * quidditch resource — server script (the authority). + * + * Everything that decides an outcome lives here and runs off `player.position`, which the server + * owns: goal crossings, snitch catches, bludger hits and possession. The client is a renderer and a + * trigger source; it is never trusted with a result. + * + * Three facts from the RE shape this file and are worth knowing before editing it: + * + * - A goal is a SEGMENT-vs-plane test, not a point test and not a trigger volume. The hoop's own + * aperture only overlaps Pawns (so it can register a player flying through, never the ball), and + * a naive point sample at 20 Hz silently drops goals above ~3540 cm/s. + * - There is NO possession arbitration in the game. Two casters can hold the same object and both + * apply impulses, so the lock below is mandatory rather than belt-and-braces. + * - The snitch actor is client-local and random-driven, so two clients would otherwise see two + * different snitches. The server owns the point; clients run the actor as a puppet. + * + * Public testing commands: + * /quidditch show state + * /quidditch ball drop ONE quaffle in front of you, no session (the Accio probe) + * /quidditch ballclear remove it + * /quidditch cast [on|off] unlock casting while broom-mounted, with no session running + * /quidditch hoops [dist] [axisDeg] [spacing] [yawOffset] re-place the hoops live + * /quidditch hoopheight [centre] set hoop heights in cm above the deck + * /quidditch trail respawn the snitch with a different trail scale + * /quidditch open open the lobby panel + * /quidditch start start a mode (snitchHunt | practice | match) + * /quidditch stop end the session + * /quidditch team pick a side before kickoff + * /quidditch role pick a position (chaser | beater | keeper | seeker); live mid-match + * /quidditch join open the position picker, or take a seat in a running match + * /quidditch pitch teleport to the pitch, with a broom if you have not got one + */ + +const RESOURCE = "hmp-quidditch"; +const Hmp = Imports.get("hmp-lib"); +const Core = Imports.get("hmp-core"); +const Activities = Imports.get("hmp-activities"); +const Ui = Imports.get("hmp-ui"); +const Houses = Imports.get("hmp-houses"); +const Inventory = Imports.get("hmp-inventory"); +const Interact = Imports.get("hmp-interact"); +const FoundationAudio = Imports.get("hmp-audio"); +const FoundationBlips = Imports.get("hmp-blips"); +const FoundationWorld = Imports.get("hmp-world"); +const logger = Hmp.logger.create(RESOURCE); +const AUDIO_OWNER = { resource: RESOURCE, id: "match" }; + +logger.info("Server loading"); + +const CONFIG = require("../quidditch.config.js"); + +// Subcommands that start, stop, retune or re-place the match. The rest (state, team, role, join, +// pitch, results, why, ballwhere) are the documented player surface and stay open. +const MANAGE_SUBS = new Set([ + "call", "cancel", "start", "stop", "open", "manager", "ball", "ballclear", + "bannertest", "banner", "bat", "bludgers", "trail", "hoopheight", "hoops", "cast", +]); + +/** + * Who may call, start, stop and tune a match, per `control` in quidditch.config.js: holders of an + * hmp-core group at or above a grade, or anyone. With `anyoneWhenNoStaff`, a pitch with no qualifying + * player online opens up to everyone rather than sitting locked until an admin logs in. + */ +async function canManage(player) { + const control = CONFIG.control || {}; + if (control.who === "anyone") return true; + const group = control.group || "admin"; + const grade = control.minimumGrade ?? 1; + const qualifies = async (p) => { + try { return await Core.groups.has(p, group, grade); } + catch (_) { return false; } + }; + if (await qualifies(player)) return true; + if (control.anyoneWhenNoStaff === false) return false; + const others = PlayerManager.getAll().filter((p) => p.id !== player.id); + return !(await Promise.all(others.map(qualifies))).some(Boolean); +} + +async function requireManager(player) { + if (await canManage(player)) return true; + const control = CONFIG.control || {}; + const grade = control.minimumGrade ?? 1; + const text = `Managing a match needs the ${control.group || "admin"} group${grade > 1 ? ` at grade ${grade}` : ""}.`; + try { player.sendChat(text); } catch (_) {} + try { Ui.notify(player, { title: "Quidditch", description: text, tone: "warning" }); } catch (_) {} + return false; +} + +const P = CONFIG.pitch; +const CENTRE = P.centre; +const DECK_Z = CENTRE.z + (P.deckOffset || 0); +const TICK_MS = Math.max(20, Math.floor(1000 / (CONFIG.scoring.tickHz || 20))); + +const HOUSES = ["gryffindor", "slytherin", "hufflepuff", "ravenclaw"]; +const ACTIVITY_ID = "quidditch"; +const unregisterActivity = Activities.definitions.register({ + id: ACTIVITY_ID, + resource: RESOURCE, + label: "Quidditch", + description: "A match, practice session, or Snitch hunt on the Hogwarts pitch.", + minimumPlayers: 1, + maximumPlayers: 15, + requireReady: false, + requiresCharacter: true, + exclusive: true, + disconnectPolicy: "remove", + defaultVisibility: "public", + scope: "areaAndVirtualWorld", + lobbyTtlSeconds: (CONFIG.lobby && CONFIG.lobby.joinSeconds) || 120, +}); + +/** + * The character's actual house. `player.house` is a server-side property maintained by the `house` + * resource, which writes through to the native `UIManager::SetPlayerHouseEnum` path — so this is the + * house the player is wearing robes for, not a guess. + * + * Returns null for "Unaffiliated" or anything unreadable, and the caller falls back. + */ +function playerHouse(player) { + try { + const h = String(player.house || "").toLowerCase(); + return HOUSES.includes(h) ? h : null; + } + catch (_) { return null; } +} + +// ---------------------------------------------------------------- geometry + +const v = (x, y, z) => ({ x, y, z }); +const add = (a, b) => v(a.x + b.x, a.y + b.y, a.z + b.z); +const sub = (a, b) => v(a.x - b.x, a.y - b.y, a.z - b.z); +const scale = (a, k) => v(a.x * k, a.y * k, a.z * k); +const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z; +const len = (a) => Math.sqrt(dot(a, a)); +const norm = (a) => { const l = len(a) || 1; return scale(a, 1 / l); }; +const dist = (a, b) => len(sub(a, b)); +const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); + +/** + * Build the six hoops from the pitch's measured long axis. The goal-plane normal is the axis + * itself, pointing back toward the centre, which is what the segment test crosses. + */ +// Live override for how many bludgers a session starts with; null = whatever the config says. +let bludgerCount = null; + +// Live overrides for the goal banners. Same idea as TUNE for the hoops: the panel and the config +// merge into one set, so tuning one number does not discard another. +let BTUNE = {}; +const bcfg = () => { + // One key at a time with its own fallback. Object.assign over a bag of `P.banner*` lookups + // writes `undefined` for any key the config does not carry, and an undefined REPLACES the + // default beneath it rather than falling back to it -- so adding a slider whose key was not + // in the config zeroed the very value it was meant to expose. + const from = (key, dflt) => { + const val = P[key]; + return typeof val === "number" || typeof val === "boolean" ? val : dflt; + }; + return Object.assign({ + yaw: from("bannerYawOffset", 0), + lift: from("bannerLift", 250), + scale: from("bannerScale", 3), + side: from("bannerSide", 0), + fwd: from("bannerFwd", 0), + hoopYaw: from("bannerHoopYaw", from("bannerYawOffset", 0)), + hoopGap: from("bannerHoopGap", 220), + hoopScale: from("bannerHoopScale", 1.5), + hoopSide: from("bannerHoopSide", 0), + hoopFwd: from("bannerHoopFwd", 0), + perHoop: from("bannerPerHoop", true), + }, BTUNE); +}; + +let TUNE = {}; // live overrides on hoopGeometry; every tuning surface merges into this + +function buildHoops(over) { + if (over) Object.assign(TUNE, over); + const g = Object.assign({}, P.hoopGeometry, TUNE); + const a = (g.axisDeg * Math.PI) / 180; + const along = v(Math.cos(a), Math.sin(a), 0); // pitch length + const across = v(-Math.sin(a), Math.cos(a), 0); // pitch width + const out = []; + for (const end of [1, -1]) { + g.heights.forEach((h, i) => { + const lateral = (i - (g.heights.length - 1) / 2) * g.spacing; + const height = Math.max(h, P.hoopMinHeight); + const flat = add(add(CENTRE, scale(along, end * g.goalDistance)), scale(across, lateral)); + const pos = v(flat.x, flat.y, DECK_Z + height); + // The ring must FACE down the pitch, not sit square to the world. UE yaw is degrees + // from +X toward +Y, which is the same convention axisDeg is measured in. + // `end` is which half the hoop stands in. A goal is only a goal at the end you are + // ATTACKING, so the sides have to be distinguishable before anyone can score wrongly. + out.push({ pos, end, normal: scale(along, -end), yaw: g.axisDeg + (g.yawOffset || 0) }); + }); + } + return out; +} +let HOOPS = buildHoops(); + +/** + * A house banner over each goal, so a player in the air can see whose end they are at. + * + * Only meaningful when two sides actually defend ends — in a free-for-all every hoop is everyone's, + * and a banner would be claiming something that is not true. + */ +function bannerPayload() { + if (!S || !S.defends) return []; + const b = bcfg(); + const g = Object.assign({}, P.hoopGeometry, TUNE); + const a = (g.axisDeg * Math.PI) / 180; + const along = v(Math.cos(a), Math.sin(a), 0); // down the pitch + const across = v(-Math.sin(a), Math.cos(a), 0); // across it + const hang = (P.bannerDropUnits || 600); + const out = []; + + // ONE formula for both sizes. They used to derive yaw from different bases — the big one off the + // pitch axis, the small ones off the ring's own yaw — so they disagreed with each other on the + // same end, and each was right on a different side. Both now take the ring's facing, the same + // per-end flip, and their own offset, which is the only term a slider needs to touch. + const flip = (end) => (end > 0 ? 180 : 0); + + // ...and one BASIS per end, for the same reason. The two ends face 180 apart, so the banner's + // own forward and its own right are both reversed at one of them. `fwd` mirrored and `side` + // did not, which made the pair half local and half world: dial `side` until one end is + // perfect and the other is off by twice the value, mirrored. Nothing about the yaw shows it, + // because the yaw was already right. + const axes = (end) => ({ fwd: scale(along, end), side: scale(across, end) }); + + for (const [team, end] of Object.entries(S.defends)) { + const mesh = (P.goalBanners || {})[team]; + if (!mesh) continue; + const ring = HOOPS.filter((h) => h.end === end); + if (!ring.length) continue; + const tallest = ring.reduce((best, h) => (h.pos.z > best.pos.z ? h : best), ring[0]); + + // The big one, hanging above the tallest ring. Its spawn point is its TOP edge, so the lift + // has to carry its own hanging length as well as the gap we want. + const ax = axes(end); + const at = add(add(tallest.pos, scale(ax.fwd, b.fwd)), scale(ax.side, b.side)); + out.push({ + mesh, team, + x: at.x, y: at.y, z: at.z + b.lift + hang * b.scale, + yaw: tallest.yaw + flip(end) + b.yaw, + scale: b.scale, + }); + + if (!b.perHoop) continue; + for (const h of ring) { + const p = add(add(h.pos, scale(ax.fwd, b.hoopFwd)), scale(ax.side, b.hoopSide)); + out.push({ + mesh, team, + x: p.x, y: p.y, z: p.z - b.hoopGap, + yaw: h.yaw + flip(end) + b.hoopYaw, + scale: b.hoopScale, + }); + } + } + return out; +} +// Banners are plain server world objects, so they stream to everyone in range — late joiners and +// spectators included — and replacing the set is the only bookkeeping they need. +let bannerKeys = []; +function placeBanners(banners) { + for (const key of bannerKeys) { try { WorldObject.destroy(key); } catch (_) {} } + bannerKeys = []; + banners.forEach((b, i) => { + const key = `quidditch:banner:${i}`; + try { + if (WorldObject.create(key, b.mesh, { x: b.x, y: b.y, z: b.z, yaw: b.yaw, scale: b.scale, collision: false })) bannerKeys.push(key); + } catch (e) { + console.log(`[QUIDDITCH] banner ${b.mesh} did not spawn: ${e.message}`); + } + }); +} + +// Bludgers are server world objects too: the tick writes their position and every client in range +// interpolates it, so late joiners and spectators see them without a per-player stream. +const MAX_BLUDGERS = 8; +const bludgerKey = (i) => `quidditch:bludger:${i}`; +function placeBludger(b, i) { + try { + b.obj = WorldObject.create(bludgerKey(i), CONFIG.bludgers.mesh, { + x: b.pos.x, y: b.pos.y, z: b.pos.z, scale: CONFIG.bludgers.scale || 1, collision: false, + fx: CONFIG.bludgers.trail, fxSocket: CONFIG.bludgers.trailSocket, + }) || null; + } catch (e) { + b.obj = null; + console.log(`[QUIDDITCH] bludger ${i} did not spawn: ${e.message}`); + } +} +function clearBludgers() { + for (let i = 0; i < MAX_BLUDGERS; i++) { try { WorldObject.destroy(bludgerKey(i)); } catch (_) {} } +} + +// The quaffle is a server physics object: the nearest client simulates it, its carrier holds it, +// and where it is comes from `obj.position`, not from a reporter we elected ourselves. +const QUAFFLE_KEY = "quidditch:quaffle"; +const TEST_QUAFFLE_KEY = "quidditch:testball"; +function spawnQuaffle(key, at) { + const b = CONFIG.ball || {}; + if (!b.mesh) return null; + try { + return WorldObject.create(key, b.mesh, { x: at.x, y: at.y, z: at.z, physics: true, massKg: b.massKg || 2 }) || null; + } catch (e) { + console.log(`[QUIDDITCH] quaffle did not spawn: ${e.message}`); + return null; + } +} +function clearQuaffle() { + try { WorldObject.destroy(QUAFFLE_KEY); } catch (_) {} +} + +// Race rings are Blueprints too; each client raises the grow-in on the one it streams. +const RING_CLASS = "/Game/Gameplay/BroomRaces/Blueprints/BP_RaceRing.BP_RaceRing_C"; +const hoopKey = (i) => `quidditch:hoop:${i}`; +let hoopCount = 0; +function clearHoopObjects() { + for (let i = 0; i < hoopCount; i++) { try { WorldObject.destroy(hoopKey(i)); } catch (_) {} } + hoopCount = 0; +} +function placeHoops() { + clearHoopObjects(); + HOOPS.forEach((h, i) => { + try { WorldObject.create(hoopKey(i), RING_CLASS, { x: h.pos.x, y: h.pos.y, z: h.pos.z, yaw: h.yaw }); } + catch (e) { console.log(`[QUIDDITCH] hoop ${i} did not spawn: ${e.message}`); } + }); + hoopCount = HOOPS.length; +} + +// One client flies the shipped snitch Blueprint for everyone. The flight model here only steers it +// through the object's state, and a catch is judged against where that client says it is. +const SNITCH_CLASS = "/Game/Levels/WorldEvents/Blueprints/LooseSnitch/BP_Loose_Snitch.BP_Loose_Snitch_C"; +const SNITCH_KEY = "quidditch:snitch"; +function spawnSnitch() { + const c = CONFIG.snitch; + const at = add(CENTRE, v(0, 0, 2500)); + S.snitch.sentRoam = null; + S.snitch.sentSpeed = null; + try { + S.snitch.obj = WorldObject.create(SNITCH_KEY, SNITCH_CLASS, { + x: at.x, y: at.y, z: at.z, scale: c.scale || 1, simulated: true, + fx: (c.trailScale || 0) > 0 ? c.trail : "", + }) || null; + } catch (e) { + S.snitch.obj = null; + console.log(`[QUIDDITCH] snitch did not spawn: ${e.message}`); + } +} +function clearSnitch() { + try { WorldObject.destroy(SNITCH_KEY); } catch (_) {} + if (S) S.snitch.obj = null; +} + +function pushBanners() { + const banners = bannerPayload(); + if (!banners.length) { + console.log("[QUIDDITCH] no goal banners: no ends assigned, or no mesh for those houses"); + } + placeBanners(banners); +} + +// `preview` places them with no session running, so the tuning commands can be checked by eye. +function pushHoops(preview) { + if (S || preview) placeHoops(); + pushBanners(); // the hoops moved, so the markers over them have to follow +} + +function tuning() { + return Object.assign({}, P.hoopGeometry, TUNE); +} + +/** + * Did the segment a->b cross the hoop's disc? Returns the crossing side (+1 through the front, + * -1 through the back) or 0. The segment is what makes this reliable at speed — sampling positions + * and testing each one in isolation is the version that loses goals. + */ +function crossedHoop(hoop, a, b) { + const n = hoop.normal; + const da = dot(sub(a, hoop.pos), n); + const db = dot(sub(b, hoop.pos), n); + if ((da > 0) === (db > 0)) return 0; + const t = da / (da - db || 1e-6); + const hit = add(a, scale(sub(b, a), t)); + const radial = sub(hit, hoop.pos); + const inPlane = sub(radial, scale(n, dot(radial, n))); + if (len(inPlane) > (CONFIG.scoring.hoopRadius || 383)) return 0; + return da > 0 ? 1 : -1; +} + +function insideEnvelope(p) { + const e = P.envelope; + return Math.abs(p.x - CENTRE.x) <= e.xy && Math.abs(p.y - CENTRE.y) <= e.xy + && p.z <= CENTRE.z + e.up && p.z >= CENTRE.z - e.down; +} + +// ---------------------------------------------------------------- session state + +let S = null; // null = idle + +function freshSession(mode) { + return { + mode, + startedAt: Date.now(), + endsAt: Date.now() + (CONFIG.scoring.matchSeconds || 900) * 1000, + players: new Map(), // playerId -> { player, team, role, lastPos, heldUntil } + score: { gryffindor: 0, slytherin: 0, hufflepuff: 0, ravenclaw: 0 }, + ball: { id: 0, pos: add(CENTRE, v(0, 0, 2000)), prev: null, carrier: null, loose: true }, + // `releaseAt` is when it is let out; `releasedAt` is when it was. Everything about the + // snitch -- stepping, catching, fatigue, the HUD -- is gated on the second being set. + snitch: { pos: null, target: null, hidden: false, legEnds: 0, holder: null, heldMs: 0, + caught: false, releaseAt: 0, releasedAt: 0 }, + // Seats emptied by a disconnect, keyed by NAME: a reconnecting player is a new connection + // id, and the id is the one thing about them that did change. + vacated: new Map(), + bludgers: [], + stunned: new Map(), // playerId -> until ms + ambience: null, // ids of the looping stadium beds, stopped in endSession + defends: null, // team -> which pitch end (+1/-1) it keeps; null = free-for-all + + finished: false, + }; +} + +// Events.onClient hands the payload already PARSED. JSON.parse on an object throws, and a throw +// inside a try/catch here reads as "the client sent nothing" — silent, and it killed four handlers. +function payload(raw) { + if (raw && typeof raw === "object") return raw; + try { return JSON.parse(raw || "{}"); } catch (_) { return null; } +} + +const seats = () => (S ? [...S.players.values()] : []); +const seatOf = (player) => (S ? S.players.get(player.id) : undefined); + +function broadcast(event, payload) { + for (const seat of seats()) { try { seat.player.emit(event, JSON.stringify(payload)); } catch (_) {} } +} + +function clearMatchBlips() { + FoundationBlips.markers.clear(RESOURCE); +} + +function syncMatchBlips() { + if (!S) { clearMatchBlips(); return; } + const audience = seats().map((seat) => seat.player); + const updates = []; + if (S.ball && S.ball.pos) updates.push(FoundationBlips.markers.upsert({ + resource: RESOURCE, id: "quaffle", kind: "marker", position: S.ball.pos, + label: "Quaffle", showOnHud: true, showDistance: false, ttl: 2, audience, + })); + // Marks the snitch its simulator flies, which is the one a catch is judged against. + const snitchAt = S.snitch && S.snitch.obj ? S.snitch.obj.position : null; + if (CONFIG.snitch.blip && snitchAt && !S.snitch.hidden) { + const at = v(snitchAt.x, snitchAt.y, snitchAt.z); + updates.push(FoundationBlips.markers.upsert({ resource: RESOURCE, id: "snitch", kind: "marker", + position: at, label: "Snitch", showOnHud: true, showDistance: true, ttl: 2, audience })); + updates.push(FoundationBlips.markers.upsert({ resource: RESOURCE, id: "snitch-radius", kind: "circle", + position: at, radius: 8, ttl: 2, audience })); + } else { + FoundationBlips.markers.remove("snitch", RESOURCE); + FoundationBlips.markers.remove("snitch-radius", RESOURCE); + } + for (let i = 0; i < 8; i++) { + const bludger = CONFIG.bludgers.blip && S.bludgers[i]; + if (bludger) updates.push(FoundationBlips.markers.upsert({ resource: RESOURCE, id: `bludger-${i}`, + kind: "circle", position: bludger.pos, radius: 3, ttl: 2, audience })); + else FoundationBlips.markers.remove(`bludger-${i}`, RESOURCE); + } + void Promise.all(updates).catch((error) => logger.warn(`Could not update match blips: ${error}`)); +} + +function announce(text) { + for (const seat of seats()) { + try { seat.player.sendChat(text); } catch (_) {} + try { Ui.notify(seat.player, { title: "Quidditch", description: text }); } catch (_) {} + } +} + +// ---------------------------------------------------------------- sound + +const SFX = CONFIG.sounds || {}; + +/** Every post is optional: an empty or missing event name silences that cue rather than throwing. */ +function sfxAll(event) { + if (!event) return 0; + try { return FoundationAudio.sounds.playForAll(AUDIO_OWNER, event) || 0; } catch (_) { return 0; } +} + +function sfxAt(event, p) { + if (!event || !p) return 0; + try { return FoundationAudio.sounds.playAt(AUDIO_OWNER, event, p, { range: 30000 }) || 0; } catch (_) { return 0; } +} + +function sfxFor(player, event) { + if (!event || !player) return 0; + try { return FoundationAudio.sounds.playForPlayer(AUDIO_OWNER, player, event) || 0; } catch (_) { return 0; } +} + +/** The side that scored hears a cheer; everyone else hears the crowd's disappointment. */ +function sfxGoal(team) { + if (!team) { sfxAll(SFX.goalNeutral); } + else { + for (const seat of seats()) { + sfxFor(seat.player, seat.team === team ? SFX.goalCheer : SFX.goalGroan); + } + } + sfxAll(SFX.goalSting); +} + +/** + * The stadium beds are LOOPS, so unlike every other cue here they have to be stopped by hand — a + * one-shot ends itself, a loop outlives the session and follows the player off the pitch. + */ +function startAmbience() { + if (!S) return; + S.ambience = [sfxAll(SFX.ambienceField), sfxAll(SFX.ambienceStands)].filter(Boolean); +} + +function stopAmbience() { + if (!S || !S.ambience) return; + for (const id of S.ambience) { try { FoundationAudio.sounds.stop(AUDIO_OWNER, id); } catch (_) {} } + S.ambience = null; +} + +/** + * The bar across the top: two sides, the clock, and who holds the quaffle. + * + * Sides come from the FIXTURE where there is one, so left and right mean the end each house + * keeps and match the banners hanging over those hoops. Without ends -- a free-for-all, a solo + * session -- it falls back to the distinct teams actually seated, and a missing side renders as + * nothing rather than as a team on nil. + */ +function scoreTree() { + if (!S) return null; + const colours = CONFIG.houseColours || {}; + const holder = ballHolder(); + let order; + if (S.defends) { + // +1 on the left, -1 on the right, so the bar reads in the same order as the pitch. + order = Object.keys(S.defends).sort((a, b) => S.defends[b] - S.defends[a]); + } else { + order = [...new Set(seats().map((x) => x.team).filter(Boolean))]; + } + const side = (team) => (team ? { + team, + colour: colours[team] || "#8a8a8a", + score: S.score[team] || 0, + holding: !!holder && holder.team === team, + } : null); + return { + left: side(order[0]), + right: side(order[1]), + mode: (CONFIG.modes[S.mode] || {}).label || S.mode, + endsAt: S.endsAt, + }; +} + +/** Cheap enough to send whole: four numbers and two strings, only when one of them moved. */ +function pushScore(only) { + const tree = scoreTree(); + if (!tree) return; + const targets = only ? [only] : seats().map((x) => x.player); + for (const p of targets) { try { p.emit("quidditch:scoreboard", JSON.stringify(tree)); } catch (_) {} } +} + +function publishScoreboard() { + if (!S) { try { World.state?.remove("quidditchMatch"); } catch (_) {} return; } + const payload = { + mode: S.mode, + score: S.score, + endsAt: S.endsAt, + players: seats().map((s) => ({ id: s.player.id, team: s.team, role: s.role })), + }; + try { World.state?.set("quidditchMatch", payload); } catch (_) {} +} + +// ---------------------------------------------------------------- snitch + +function snitchVolumeSample() { + const c = CONFIG.snitch.volume; + return add(CENTRE, v((Math.random() * 2 - 1) * c.xy, (Math.random() * 2 - 1) * c.xy, + c.minZ + Math.random() * (c.maxZ - c.minZ))); +} + +// 1 while fresh, decaying to `floor` once the hunt has run long enough. Everything the snitch does +// to stay away from you scales by this, and the catch radius grows as its inverse. +function snitchFatigue(now) { + const f = CONFIG.snitch.fatigue; + if (!f || !S.snitch.releasedAt) return 1; + // From RELEASE, not from kickoff: a snitch let out at five minutes has done no flying yet. + const elapsed = (now - S.snitch.releasedAt) / 1000 - (f.graceSec || 0); + if (elapsed <= 0) return 1; + const t = clamp(elapsed / (f.rampSec || 180), 0, 1); + return 1 - t * (1 - (f.floor ?? 0.45)); +} + +/** + * How far through the tiring, 0..1 — NOT the speed multiplier. + * + * `snitchFatigue` bottoms out at `floor`, so `1 - fatigue` maxes out at `1 - floor`: with the + * default 0.45 the HUD read "tiring 55%" at maximum tiredness and could never reach 100%, and + * `radiusBonus` delivered 55% of its configured centimetres. Progress is the honest number for both. + */ +/** + * Let it out. Announced everywhere at once, because it changes what everybody on the pitch is + * doing -- the seekers start hunting and everyone else has a clock on them now. + */ +function releaseSnitch() { + if (!S || S.snitch.releasedAt) return; + S.snitch.releasedAt = Date.now(); + spawnSnitch(); + broadcast("quidditch:resnitch", { id: S.snitch.obj ? S.snitch.obj.id : 0, released: true }); + announce("The Snitch is released!"); + broadcast("quidditch:banner", { size: "goal", text: "The Snitch is released" }); + sfxAll(SFX.snitchRelease || SFX.goalSting); + for (const seat of seats()) pushHud(seat); // the countdown becomes the proximity bar +} + +function snitchWear(now) { + const f = CONFIG.snitch.fatigue; + if (!f || !S.snitch.releasedAt) return 0; + // From RELEASE, not from kickoff: a snitch let out at five minutes has done no flying yet. + const elapsed = (now - S.snitch.releasedAt) / 1000 - (f.graceSec || 0); + if (elapsed <= 0) return 0; + return clamp(elapsed / (f.rampSec || 180), 0, 1); +} + +/** + * The distance the CATCH is judged on — one definition, used by the test and by the HUD. + * + * It is measured to the snitch every client sees, which is the one its simulator flies. The point + * the flight model steers by only aims that snitch; nobody can see it. + */ +function catchDistance(seat) { + const sn = S.snitch; + const at = sn.obj ? sn.obj.position : sn.pos; + if (!seat.lastPos || !at) return Infinity; + return dist(seat.lastPos, v(at.x, at.y, at.z)); +} + +function catchRadiusNow(now) { + const f = CONFIG.snitch.fatigue; + return CONFIG.snitch.catchRadius + snitchWear(now) * ((f && f.radiusBonus) || 0); +} + +function seekers() { + const all = seats(); + // snitchHunt is the mode where EVERYONE hunts. That is the game there, not a lapse in the + // rule below, so it is exempt by mode rather than by an empty role list. + if (S.mode === "snitchHunt") return all; + + const named = all.filter((seat) => roleAllowed(seat, CONFIG.snitch.catchRoles)); + if (named.length) return named; + + // Nobody on the pitch holds the role, so the snitch would be uncatchable and a match could + // never end by catch. The roster still gets it -- but out loud, once. A rule that suspends + // itself in silence is the kind you spend an evening disbelieving. + if (!S.saidNoSeeker) { + S.saidNoSeeker = true; + console.log("[QUIDDITCH] no seat holds a catch role — the snitch is open to the roster"); + announce("No Seeker on the pitch — the Snitch is anyone's."); + } + return all; +} + +function randomUnit() { + // Biased flat: a snitch that spends its time climbing and diving is much harder to read than + // one that works the horizontal plane, and the volume is far wider than it is tall. + const a = Math.random() * Math.PI * 2; + const z = (Math.random() * 2 - 1) * 0.45; + return norm(v(Math.cos(a), Math.sin(a), z)); +} + +/** + * One leg = one short hop along a mostly-continuous heading. The previous heading carries most of + * the weight, a little randomness turns it, and a nearby seeker pushes it away. Sampling a fresh + * point from the whole volume instead makes it teleport and become uncatchable. + */ +function nextSnitchLeg(now) { + const sn = S.snitch; + const cfg = CONFIG.snitch; + if (!sn.heading) sn.heading = randomUnit(); + + let nearest = null; + for (const seat of seekers()) { + const p = seat.lastPos; + if (!p) continue; + if (!nearest || dist(p, sn.pos) < dist(nearest, sn.pos)) nearest = p; + } + const threatened = nearest && dist(nearest, sn.pos) < cfg.fleeRadius; + + let dir = add(scale(sn.heading, cfg.momentum), scale(randomUnit(), cfg.wander)); + if (threatened) dir = add(dir, scale(norm(sub(sn.pos, nearest)), cfg.fleeStrength * snitchFatigue(now))); + sn.heading = norm(dir); + + const wear = snitchFatigue(now); + const speed = (threatened ? cfg.fleeSpeed : cfg.cruiseSpeed) * wear; + let target = add(sn.pos, scale(sn.heading, speed * cfg.legSeconds)); + + // Bounce off the airspace walls rather than clamping flat — a clamped snitch pins itself into a + // corner and hovers there. + const c = cfg.volume; + const lo = v(CENTRE.x - c.xy, CENTRE.y - c.xy, CENTRE.z + c.minZ); + const hi = v(CENTRE.x + c.xy, CENTRE.y + c.xy, CENTRE.z + c.maxZ); + const axes = [["x", lo.x, hi.x], ["y", lo.y, hi.y], ["z", lo.z, hi.z]]; + for (const [axis, min, max] of axes) { + if (target[axis] < min || target[axis] > max) { + sn.heading[axis] = -sn.heading[axis]; + target[axis] = clamp(target[axis], min, max); + } + } + + sn.target = target; + sn.legEnds = now + cfg.legSeconds * 1000; + + // No feinting on its last legs. A spent snitch that still vanishes reads as a trick rather than + // exhaustion, and with floor 0 it would be the only thing it could still do. + if (threatened && snitchWear(now) < 0.9 && Math.random() < cfg.feintChance) { + sn.hidden = true; + setTimeout(() => { if (S && S.snitch === sn) sn.hidden = false; }, cfg.feintHideMs); + } +} + +function stepSnitch(now) { + const sn = S.snitch; + if (sn.caught) return; + if (!sn.pos) { sn.pos = snitchVolumeSample(); sn.target = sn.pos; sn.legEnds = 0; } + // Frozen still runs the catch check below — that is the whole point of freezing it, to test the + // catch against a target that is definitely not moving away from you. + if (!sn.frozen && now >= sn.legEnds) nextSnitchLeg(now); + + if (!sn.frozen) { + const legLeft = Math.max(1, sn.legEnds - now); + const k = clamp(TICK_MS / legLeft, 0, 1); + sn.pos = add(sn.pos, scale(sub(sn.target, sn.pos), k)); + } + + // Steer the puppet at the LIVE authoritative point, not the leg's end. Aiming it at the end let + // the BP fly its own randomly-bulged Bezier there while the server eased straight toward it — + // so the snitch you can see and the snitch being tested for a catch drifted apart mid-leg, and + // catching the visible one did nothing. Costs some of the organic swoop; worth it. + if (!sn.beat || now - sn.beat >= (CONFIG.snitch.steerMs || 200)) { + sn.beat = now; + // The BP's OWN play-rate has to tire too. Steering only moves the box it picks destinations + // in; left at its default the snitch keeps flying full-speed arcs around a stationary box, + // which is exactly what "100% tired and still zipping about" was. + const mult = sn.frozen ? 0 : snitchFatigue(now); + // ROAM is what stops a tired snitch, not speed: a leg is a lerp over a 1 s timeline, so its + // duration is 1/rate REGARDLESS of distance. Shrinking the box shortens the leg; cutting the + // rate only makes the same leg glacial. + const roam = Math.max(CONFIG.snitch.roamMin ?? 60, (CONFIG.snitch.roam ?? 3000) * mult); + // The rate therefore has a real floor. Driving it near zero — which freezing used to do — + // strands the mesh mid-flight crawling toward a box it never reaches, so the snitch you can + // see ends up tens of metres from the one being tested, and neither freeze nor "bring to me" + // can recover it. At 1.0 a leg always completes within a second, so the puppet keeps up. + const base = CONFIG.snitch.bpSpeed ?? 3.0; + const rate = Math.round(Math.max(CONFIG.snitch.bpSpeedMin ?? 1.0, base * mult) * 1000) / 1000; + const reach = Math.round(roam); + if (sn.obj) { + try { + sn.obj.state.set("snitch.x", sn.pos.x); + sn.obj.state.set("snitch.y", sn.pos.y); + sn.obj.state.set("snitch.z", sn.pos.z); + if (rate !== sn.sentSpeed) { sn.obj.state.set("snitch.speed", rate); sn.sentSpeed = rate; } + if (reach !== sn.sentRoam) { sn.obj.state.set("snitch.roam", reach); sn.sentRoam = reach; } + } catch (e) { + logger.warn(`Snitch steering failed: ${e.message}`); + } + } + } + + for (const seat of seekers()) { + const p = seat.lastPos; + if (!p) continue; + if (catchDistance(seat) <= catchRadiusNow(now)) { + if (sn.holder !== seat.player.id) { + sn.holder = seat.player.id; + sn.heldMs = 0; + try { seat.player.emit("quidditch:catching", JSON.stringify({ on: true, ms: CONFIG.snitch.catchHoldMs })); } catch (_) {} + } + else { sn.heldMs += TICK_MS; } + if (sn.heldMs >= CONFIG.snitch.catchHoldMs) { catchSnitch(seat); return; } + } + else if (sn.holder === seat.player.id) { + // A brief grace, so a single tick of drift does not reset a hold that is going fine. + sn.heldMs -= TICK_MS * 2; + if (sn.heldMs <= 0) { + sn.holder = null; + sn.heldMs = 0; + try { seat.player.emit("quidditch:catching", JSON.stringify({ on: false })); } catch (_) {} + } + } + } +} + +function catchSnitch(seat) { + clearCatchBar(); + S.snitch.caught = true; + + // Never name a house at random. This read `seat.team || "gryffindor"`, so a seat that somehow + // reached here without a side had one invented for it -- and an invented house is worse than + // none, because everyone watching believes the announcement. + const team = seat.team || playerHouse(seat.player); + if (!team) console.error(`[QUIDDITCH] snitch caught by ${seat.player.id} with no team and no house`); + + if (team) S.score[team] = (S.score[team] || 0) + CONFIG.snitch.points; + pushScore(); + announce(team + ? `The Snitch is caught! ${CONFIG.snitch.points} to ${team}.` + : `The Snitch is caught! ${CONFIG.snitch.points} points, unclaimed.`); + // Crowd only here — the end-gate firework arrives a beat later from finishSession, so playing a + // sting too would stack three cues on one moment. + sfxAll(SFX.victory); + void recordLeaderboard(seat, "snitch").catch((error) => logger.warn(`Could not save snitch statistic: ${error}`)); + if (seat.stats) seat.stats.snitch++; + // Before the teardown broadcast below — in snitchHunt and match this ends the session, and + // teardown destroys the overlay view the banner would have drawn in. + if (team) { + broadcast("quidditch:banner", { + size: "snitch", house: team, text: "The Snitch is caught", + subLead: team, subTail: `· +${CONFIG.snitch.points}`, holdMs: 4200, + }); + } + broadcast("quidditch:snitchCaught", { by: seat.player.id }); + clearSnitch(); + publishScoreboard(); + if (S.mode === "snitchHunt" || S.mode === "match") { + finishSession("snitch", CONFIG.scoring.celebrateMs ?? 14000); + } +} + +// ---------------------------------------------------------------- ball + goals + +function ballHolder() { + if (!S || !S.ball.carrier) return null; + return S.players.get(S.ball.carrier) || null; +} + +/** + * Whose simulation of the loose ball the server believes. + * + * The server integrates no physics of its own, so without this it never learns that a loose ball + * fell: it spawns at centre+20 m, every client's copy drops to the deck, and the server keeps + * testing pickups against the empty air the ball started in. That deadlocked possession outright — + * you could not pick the ball up, so you could not throw it, so `lastThrower` stayed null and the + * only path that corrected the position never opened. + * + * The thrower reports while a throw is in the air. Otherwise it is the lowest player id present — + * arbitrary but *stable*, which is the property that matters: every client simulates its own copy + * and they drift, so exactly one has to be the one we believe. + */ +/** + * Is this seat's ROLE allowed to do this? An absent or empty list means the action carries no + * role restriction, so a config that says nothing keeps the old behaviour of anyone-may. + */ +const roleAllowed = (seat, roles) => + !Array.isArray(roles) || !roles.length || roles.includes(seat.role); + +/** Said out loud. A silent refusal is indistinguishable from the key not working. */ +function refuseByRole(seat, what, roles) { + const who = roles.map(cap1).join(" or "); + try { seat.player.emit("quidditch:notice", JSON.stringify({ text: `Only a ${who} may ${what}.` })); } catch (_) {} +} + +/** No arbitration exists in the game, so possession is decided here or not at all. */ +function grantPossession(seat) { + if (!S || S.ball.carrier === seat.player.id) return false; + // Here rather than at each call site: the wheel, the hotkey and the fallback bind all arrive + // through this one function, and an offer is only advisory -- a client names an action by id. + if (!roleAllowed(seat, CONFIG.ball.carryRoles)) { + refuseByRole(seat, "carry the Quaffle", CONFIG.ball.carryRoles); + return false; + } + if (seat.noCarryUntil && Date.now() < seat.noCarryUntil) return false; + const now = Date.now(); + if (S.ball.heldUntil && now < S.ball.heldUntil) return false; + + // Read BEFORE possession is taken, because taking it is what makes the ball no longer loose. + // Catching a ball an opponent put in the air is the Chaser's save, and it was the quietest + // thing on the pitch: the only feedback was the taker's own "You have the Quaffle", which + // nobody else could see. A pass completing stays silent -- that one is routine. + const thrower = S.ball.loose && S.ball.lastThrower && S.ball.lastThrower !== seat.player.id + ? S.players.get(S.ball.lastThrower) : null; + const intercepted = !!thrower && !!thrower.team && thrower.team !== seat.team; + + S.ball.carrier = seat.player.id; + S.ball.loose = false; + if (S.ball.obj) S.ball.obj.attachTo(seat.player, CONFIG.ball.carrySocket || "WandSocket"); + sfxAt(SFX.catch, S.ball.pos); + if (intercepted) announce(`${seat.player.nickname || "A chaser"} intercepts!`); + pushScore(); + + // Say so. A silent success is indistinguishable from a failure, and it read as one: taking the + // ball offered no feedback at all, so the natural response was to press again — and the second + // press is correctly refused, because the pickup is no longer on offer. The rejection warning + // was the only signal the mechanic gave, and it was pointing at the wrong thing entirely. + try { seat.player.emit("quidditch:notice", JSON.stringify({ text: "You have the Quaffle." })); } catch (_) {} + // Immediately, not on the next 500 ms HUD beat — the card IS the ongoing "you are holding it". + pushCarryCard(seat); + // Rebuild context now so the E bind drops the moment it stops being legal, instead of lingering + // until the next poll and inviting exactly the second press that produced the warning. + if (Events.listenerCount("rp:ui:context:poke") > 0) { + try { Events.emit("rp:ui:context:poke", seat.player); } catch (_) {} + } + return true; +} + +function dropBall(reason) { + if (!S || !S.ball.carrier) return; + const seat = S.players.get(S.ball.carrier); + S.ball.carrier = null; + S.ball.loose = true; + S.ball.heldUntil = Date.now() + 600; // brief immunity so the dropper cannot instantly re-grab + if (S.ball.obj) S.ball.obj.detach(); + pushScore(); + if (seat) { + try { seat.player.emit("quidditch:notice", JSON.stringify({ text: "You lost the Quaffle." })); } catch (_) {} + pushCarryCard(seat); + if (Events.listenerCount("rp:ui:context:poke") > 0) { + try { Events.emit("rp:ui:context:poke", seat.player); } catch (_) {} + } + } +} + +function stepBall() { + const holder = ballHolder(); + const prev = S.ball.prev; + if (holder && holder.lastPos) { S.ball.pos = add(holder.lastPos, v(0, 0, 60)); } + else if (S.ball.obj) { + const p = S.ball.obj.position; + S.ball.pos = v(p.x, p.y, p.z); + } + S.ball.prev = S.ball.pos; + if (!prev) return; + if (holder) { + // Haversacking: the carrier may not take it through themselves. A carried ball used to skip + // the hoop test entirely, so flying it through did nothing at all — no goal, but no + // consequence either, which is its own kind of silence. + for (const hoop of HOOPS) { + if (!crossedHoop(hoop, prev, S.ball.pos)) continue; + announce(`${holder.player.nickname || "Carrier"} took it through — no goal.`); + broadcast("quidditch:banner", { size: "goal", text: "Haversack — no goal" }); + fumble(holder, "haversack"); + resetBallToCentre(); + return; + } + return; + } + + for (const hoop of HOOPS) { + const side = crossedHoop(hoop, prev, S.ball.pos); + if (!side) continue; + // The scoring-area rule. A shot released on top of the hoop leaves no flight time, so the + // keeper never had a chance to be anywhere — it is not a save they missed, it is a save that + // was never possible. Waved off rather than scored. + const minShot = (CONFIG.keeper && CONFIG.keeper.minShotDistance) || 0; + if (minShot && S.ball.throwFrom && dist(S.ball.throwFrom, hoop.pos) < minShot) { + announce(`Too close — a shot must be taken from beyond ${Math.round(minShot / 100)} m.`); + broadcast("quidditch:banner", { size: "goal", text: "No goal — too close" }); + resetBallToCentre(); + return; + } + const scorer = S.ball.lastThrower ? S.players.get(S.ball.lastThrower) : null; + let team = scorer ? scorer.team : null; + // An own goal counts for the other side, as it does in every sport that has ends. Refusing to + // score it would make your own hoops a safe place to dump the quaffle. + let own = false; + if (team && S.defends && S.defends[team] === hoop.end) { + own = true; + team = Object.keys(S.defends).find((x) => x !== team) || null; + } + if (team) { + S.score[team] = (S.score[team] || 0) + CONFIG.scoring.goal; + pushScore(); + announce(own + ? `Own goal! ${CONFIG.scoring.goal} to ${team}.` + : `Goal! ${CONFIG.scoring.goal} to ${team}.`); + // An own goal is not the scorer's to be credited with — the points land, the stat does not. + if (!own) { + void recordLeaderboard(scorer, "goal").catch((error) => logger.warn(`Could not save goal statistic: ${error}`)); + if (scorer.stats) scorer.stats.goals++; + } + broadcast("quidditch:banner", { + size: "goal", house: team, lead: team, tail: own ? "score — own goal" : "score", + sub: `+${CONFIG.scoring.goal} · ${S.score[team]} total`, + }); + } + else { + announce("Through the hoop!"); + broadcast("quidditch:banner", { size: "goal", text: "Through the hoop" }); + } + sfxGoal(team); + broadcast("quidditch:goal", { team, hoop: hoop.pos }); + resetBallToCentre(); + publishScoreboard(); + return; + } + + if (!insideEnvelope(S.ball.pos)) resetBallToCentre(); +} + +function resetBallToCentre() { + // Loud on purpose. A ball that falls through the pitch floor leaves the envelope, gets teleported + // 35 m back up, and falls again — a silent ~2.7 s loop that reads in game as "the quaffle will + // not stay put" and makes a pickup unpressable, because the distance the offer was built from is + // stale by tens of metres the instant this fires. If this line repeats, that is the bug. + if (S.ball.resetAt && Date.now() - S.ball.resetAt < 8000) { + console.log(`[QUIDDITCH] ball reset AGAIN after ${Date.now() - S.ball.resetAt} ms ` + + `from (${Math.round(S.ball.pos.x)}, ${Math.round(S.ball.pos.y)}, ${Math.round(S.ball.pos.z)}) ` + + `— deck is ${Math.round(DECK_Z)}, envelope floor ${Math.round(CENTRE.z - P.envelope.down)}`); + } + S.ball.resetAt = Date.now(); + S.ball.pos = add(CENTRE, v(0, 0, 2000)); + S.ball.prev = null; + S.ball.carrier = null; + S.ball.loose = true; + S.ball.lastThrower = null; + S.ball.throwFrom = null; + if (S.ball.obj) { + S.ball.obj.detach(); + S.ball.obj.position = new Vector3(S.ball.pos.x, S.ball.pos.y, S.ball.pos.z); + S.ball.obj.setVelocity(0, 0, 0); + } +} + +// ---------------------------------------------------------------- bludgers + +/** + * Is this point inside a hoop's scoring area? With `end` given, only that end's hoops count. + * + * Goals use the unrestricted form -- a goal is a goal wherever the ball crosses -- so the end is + * opt-in rather than always applied. + */ +function inScoringArea(p, end) { + if (!p) return false; + const r = (CONFIG.keeper && CONFIG.keeper.scoringArea) || 2600; + return HOOPS.some((h) => (end === undefined || h.end === end) && dist(p, h.pos) <= r); +} + +/** + * The end this seat keeps, or undefined when nobody keeps anything -- a free-for-all, or a solo + * session with one side. Undefined is what makes the tackle test fall back to ANY area, which is + * the right answer there: with no ends assigned, 'your own area' does not mean anything, and + * making the tackle unusable would be a worse reading of it than making it unrestricted. + */ +const endKeptBy = (seat) => + (S && S.defends && seat && seat.team && S.defends[seat.team]) || undefined; + +/** + * Lose the ball, and lose the right to take it straight back. + * + * Dropping it is a moment; the lockout is the consequence. Without it a tackled chaser simply + * re-grabs and tries the identical thing, and nothing about the attack has to change. + */ +function fumble(seat, reason) { + if (!seat) return; + const ms = (CONFIG.keeper && CONFIG.keeper.fumbleMs) || 12000; + seat.noCarryUntil = Date.now() + ms; + if (S.ball.carrier === seat.player.id) dropBall(reason); + try { + seat.player.emit("quidditch:notice", JSON.stringify({ + text: `Fumbled — you cannot carry for ${Math.round(ms / 1000)}s.`, + })); + } catch (_) {} + pushFumbleCard(seat); +} + +const fumbleCards = new Map(); + +function pushFumbleCard(seat) { + if (Events.listenerCount("rp:ui:hud") === 0) return; + const id = seat.player.id; + const draw = () => { + const left = (seat.noCarryUntil || 0) - Date.now(); + if (left <= 0 || !S) { + const h = fumbleCards.get(id); + if (h) clearInterval(h); + fumbleCards.delete(id); + try { Events.emit("rp:ui:hud", seat.player, "quidditch:fumble", null); } catch (_) {} + return; + } + try { + Events.emit("rp:ui:hud", seat.player, "quidditch:fumble", { + kind: "meter", label: "Fumbled", value: Math.round(left), + max: (CONFIG.keeper && CONFIG.keeper.fumbleMs) || 12000, + order: 24, tone: "red", detail: `${Math.ceil(left / 1000)}s`, + }); + } catch (_) {} + }; + const prev = fumbleCards.get(id); + if (prev) clearInterval(prev); + fumbleCards.set(id, setInterval(draw, 250)); + draw(); +} + +/** + * Put hands on a carrier inside the scoring area. The Keeper's, and only the Keeper's: this is + * what makes a chaser camping the hoops a risk rather than a free shot, and it is the Keeper the + * risk is supposed to come from. The announcement below always said "Keeper"; the gate is new. + */ +function tackle(seat) { + if (!S || !seat.lastPos) return false; + const now = Date.now(); + const k = CONFIG.keeper || {}; + if (!roleAllowed(seat, k.tackleRoles)) { + refuseByRole(seat, "tackle the carrier", k.tackleRoles); + return false; + } + if (seat.tackledAt && now - seat.tackledAt < (k.tackleCooldownMs || 2000)) return false; + const carrier = ballHolder(); + if (!carrier || carrier.player.id === seat.player.id || !carrier.lastPos) return false; + if (dist(seat.lastPos, carrier.lastPos) > (k.tackleRadius || 700)) return false; + // Their OWN scoring area. This exists to make camping a hoop risky, not to turn the whole + // pitch into a wrestling match -- and a Keeper who could tackle at either end was licensed to + // chase a carrier the length of the pitch and take the ball off them in front of their own + // goal, which is the opposite of keeping. + if (!inScoringArea(carrier.lastPos, endKeptBy(seat))) return false; + seat.tackledAt = now; + announce(`${seat.player.nickname || "Keeper"} tackles ${carrier.player.nickname || "the carrier"}!`); + sfxAt(k.blockSound || "", carrier.lastPos); + fumble(carrier, "tackle"); + try { carrier.player.emit("quidditch:knock", JSON.stringify({ ms: k.tackleStunMs || 700 })); } catch (_) {} + return true; +} + +/** Deflect a loose ball along the keeper's aim — the clearing kick, not a catch. */ +function blockBall(seat, aim) { + if (!S || !S.ball.loose || S.ball.carrier || !seat.lastPos) return false; + if (!roleAllowed(seat, (CONFIG.keeper || {}).blockRoles)) { + refuseByRole(seat, "clear the ball", (CONFIG.keeper || {}).blockRoles); + return false; + } + const now = Date.now(); + if (seat.blockedAt && now - seat.blockedAt < ((CONFIG.keeper && CONFIG.keeper.blockCooldownMs) || 800)) return false; + if (dist(seat.lastPos, S.ball.pos) > ((CONFIG.keeper && CONFIG.keeper.blockRadius) || 1000)) return false; + seat.blockedAt = now; + + const dir = aim && typeof aim.x === "number" + ? norm(v(aim.x, aim.y, aim.z)) : norm(sub(S.ball.pos, seat.lastPos)); + const speed = (CONFIG.keeper && CONFIG.keeper.blockSpeed) || 2400; + // A cleared ball belongs to nobody: the keeper should not become the scorer if it later goes in, + // and the scoring-area rule would wave that off anyway. + S.ball.lastThrower = null; + S.ball.throwFrom = null; + S.ball.prev = S.ball.pos; + if (S.ball.obj) S.ball.obj.setVelocity(dir.x * speed, dir.y * speed, dir.z * speed); + broadcast("quidditch:ballThrow", {}); + sfxAt((CONFIG.keeper && CONFIG.keeper.blockSound) || "", S.ball.pos); + announce(`${seat.player.nickname || "The keeper"} clears it!`); + return true; +} + +/** The bludger a swing would connect with: nearest inside swingRadius, or null. */ +function nearestBludger(seat) { + if (!S || !seat.lastPos || !S.bludgers.length) return null; + const reach = CONFIG.bludgers.swingRadius || 1400; + let best = null, bestD = reach; + for (const b of S.bludgers) { + const d = dist(seat.lastPos, b.pos); + if (d <= bestD) { best = b; bestD = d; } + } + return best; +} + +/** + * Bat it away. The client supplies aim only and the server re-derives the launch, exactly as a + * throw does — a swing is a throw you do not have to be holding anything for. + * + * The immunity window is what makes this feel fair rather than random: without it the bludger you + * just hit is still inside its own hit radius on the next tick and knocks you off for your trouble. + */ +function swingAt(seat, aim) { + if (!roleAllowed(seat, CONFIG.bludgers.swingRoles)) { + refuseByRole(seat, "bat a Bludger", CONFIG.bludgers.swingRoles); + return false; + } + const now = Date.now(); + if (seat.swungAt && now - seat.swungAt < (CONFIG.bludgers.swingCooldownMs || 700)) return false; + const b = nearestBludger(seat); + if (!b) return false; + seat.swungAt = now; + + // No aim reported yet? Send it away from the swinger, which is the sane default. + const dir = aim && typeof aim.x === "number" ? norm(v(aim.x, aim.y, aim.z)) : norm(sub(b.pos, seat.lastPos)); + b.vel = dir; + b.speed = CONFIG.bludgers.swingSpeed || 2600; + b.cur = CONFIG.bludgers.speedMin; + b.speedUntil = now + 2500; + b.immuneTo = seat.player.id; + b.immuneUntil = now + (CONFIG.bludgers.swingImmuneMs || 1200); + b.targetId = null; // re-picks a victim once the immunity lapses + // Whose side sent it. A Beater's job is to put a bludger on an OPPONENT, so when it stops + // flying straight it hunts the other team first rather than whoever happens to be nearest. + b.sentBy = seat.team; + sfxAt(CONFIG.bludgers.swingHit, b.pos); + try { seat.player.emit("quidditch:swung", JSON.stringify({})); } catch (_) {} + return true; +} + +function stepBludgers(now) { + if (!S.bludgers.length) return; + const targets = seats().filter((s) => s.lastPos); + if (!targets.length) return; + // Frozen holds them in place AND stops them hitting. Unlike the snitch's freeze, which keeps its + // catch test running on purpose, this one is for getting a bludger out of the way while working + // on something else — a frozen bludger that still knocked you about would defeat the point. + if (S.bludgersFrozen) return; + for (const b of S.bludgers) { + const batted = b.speedUntil && now < b.speedUntil; + // A batted bludger holds the line it was hit along. Homing straight back at the swinger + // would make the swing pointless — the whole point of hitting it is that it goes somewhere. + if (!batted) { + // Skip anyone still immune entirely — otherwise a bludger shadows a player it + // cannot hit, waiting out their window at point-blank range. + const eligible = seats().filter((s) => s.lastPos + && !((S.stunned.get(s.player.id) || 0) > now) + && !(b.immuneUntil && now < b.immuneUntil && s.player.id === b.immuneTo)); + if (!eligible.length) continue; + let target = S.players.get(b.targetId); + if (!target || !target.lastPos || !eligible.includes(target)) { + // A batted bludger goes after the other side. Only falls back to the full field when + // nobody on the other side is available — a solo session being the obvious case. + const foes = b.sentBy ? eligible.filter((s) => s.team !== b.sentBy) : []; + const pool = foes.length ? foes : eligible; + target = pool[Math.floor(Math.random() * pool.length)]; + b.targetId = target.player.id; + b.sentBy = null; // one bat, one directed hunt + } + const toward = norm(sub(target.lastPos, b.pos)); + b.vel = norm(add(scale(norm(b.vel), 1 - CONFIG.bludgers.homingStrength), + scale(toward, CONFIG.bludgers.homingStrength))); + } + // Wind-up: a homing bludger accelerates toward speedMax and is knocked back to speedMin by + // every hit and every bat, so its menace has to rebuild each time rather than being constant. + if (b.cur === undefined) b.cur = CONFIG.bludgers.speedMin; + if (!batted) { + b.cur = Math.min(CONFIG.bludgers.speedMax, b.cur + CONFIG.bludgers.accel * (TICK_MS / 1000)); + } + const speed = batted ? (b.speed || CONFIG.bludgers.speedMax) : b.cur; + b.pos = add(b.pos, scale(b.vel, speed * (TICK_MS / 1000))); + if (!insideEnvelope(b.pos)) { b.pos = add(CENTRE, v(0, 0, 2500)); b.speedUntil = 0; } + + // Tested against EVERY seat, not just the one it was homing at — a bludger batted across the + // pitch should hit whoever it reaches, and the immunity keeps it off the batter meanwhile. + for (const seat of seats()) { + if (!seat.lastPos) continue; + if (b.immuneUntil && now < b.immuneUntil && seat.player.id === b.immuneTo) continue; + if (dist(b.pos, seat.lastPos) > CONFIG.bludgers.hitRadius) continue; + knock(seat, now); + // RETREAT, rather than recoil and re-home. Nudging it back a few metres and letting it + // steer straight back made it orbit its victim at full speed — landing no further hits, + // because knock is inside their stun window, so it just circled them. It now flies off + // the way it came for a beat and cannot re-target that player until their stun ends. + b.targetId = null; + b.vel = norm(sub(b.pos, seat.lastPos)); + b.speed = CONFIG.bludgers.retreatSpeed || CONFIG.bludgers.speedMax; + b.cur = CONFIG.bludgers.speedMin; + b.speedUntil = now + (CONFIG.bludgers.retreatMs || 1500); + b.immuneTo = seat.player.id; + b.immuneUntil = now + (CONFIG.bludgers.hitImmuneMs || 5000); + break; + } + } + for (const b of S.bludgers) { + if (b.obj) { try { b.obj.position = new Vector3(b.pos.x, b.pos.y, b.pos.z); } catch (_) {} } + } +} + +/** A knock is animation plus a control lock. No damage call, no dismount, nothing that writes a save. */ +function knock(seat, now) { + const until = S.stunned.get(seat.player.id) || 0; + if (now < until) return; + S.stunned.set(seat.player.id, now + (CONFIG.bludgers.hitImmuneMs || 5000)); + if (S.ball.carrier === seat.player.id) dropBall("bludger"); + sfxAt(CONFIG.bludgers.swingHit, seat.lastPos); + try { seat.player.emit("quidditch:knock", JSON.stringify({ ms: CONFIG.bludgers.stunMs })); } catch (_) {} + pushStunCard(seat, now); +} + +// ---------------------------------------------------------------- leaderboard + +async function recordLeaderboard(seat, kind) { + if (!seat) return; + const character = Core.characters.active(seat.player); + if (!character) return; + const key = `${RESOURCE}:career`; + let row = await Core.metadata.getCharacter(character.id, key) || {}; + if (!row || typeof row !== "object" || Array.isArray(row)) row = {}; + row[kind] = (row[kind] || 0) + 1; + await Core.metadata.setCharacter(character.id, key, row); +} + +// ---------------------------------------------------------------- lobby + +/** + * The pre-match lobby. + * + * Sides and positions have to be agreed BEFORE a match, and the old path swept up whoever was + * connected and dealt roles round-robin — fine alone, meaningless with people. A referee calls it, + * everyone claims an open slot on either side, and the referee starts when the teams look right. + * + * The join clock is a default, not a rule. It exists so a called match cannot sit open forever, and + * the referee can always start early. + */ +let LOBBY = null; + +const LOBBY_ID = () => (CONFIG.lobby && CONFIG.lobby.panelId) || "quidditch:lobby"; +const lineup = () => (CONFIG.lobby && CONFIG.lobby.lineup) || { chaser: 3, beater: 2, keeper: 1, seeker: 1 }; +const lobbyTeams = () => (LOBBY && LOBBY.teams) || (CONFIG.lobby && CONFIG.lobby.teams) + || ["gryffindor", "slytherin"]; + +/** + * A fixture is between two houses, so it is only called TO those two houses. Everyone else has no + * business in the lobby and would only be told about a match they cannot join. + * + * The referee is the exception: they may be neither house, and somebody has to be able to start it. + */ +function lobbyEligible(player) { + if (!LOBBY) return false; + if (LOBBY.ffa || player.id === LOBBY.refereeId) return true; + const house = playerHouse(player); + return !!house && LOBBY.teams.includes(house); +} + +/** The side a player may claim on — their own house, and nothing else. */ +function lobbySideFor(player) { + if (!LOBBY) return null; + // In a free-for-all there is no house to play for, so every side is yours to pick from. `true` + // rather than a house name: callers test it for truthiness and compare only when it is a string. + if (LOBBY.ffa) return true; + const house = playerHouse(player); + return house && LOBBY.teams.includes(house) ? house : null; +} +const cap1 = (s) => String(s || "").replace(/^./, (c) => c.toUpperCase()); + +function openLobby(mode, referee, teamA, teamB, ffa) { + if (S) { referee.sendChat("A match is already running."); return; } + if (LOBBY) { referee.sendChat("A match has already been called."); return; } + const spec = CONFIG.modes[mode]; + if (!spec) { referee.sendChat(`Unknown mode: ${mode}`); return; } + + const fallback = (CONFIG.lobby && CONFIG.lobby.teams) || ["gryffindor", "slytherin"]; + const a = HOUSES.includes(String(teamA || "").toLowerCase()) ? String(teamA).toLowerCase() : fallback[0]; + const b = HOUSES.includes(String(teamB || "").toLowerCase()) ? String(teamB).toLowerCase() : fallback[1]; + if (a === b) { referee.sendChat("A house cannot play itself."); return; } + + const lobby = LOBBY = { + mode, + teams: [a, b], + ffa: !!ffa, + refereeId: referee.id, + endsAt: Date.now() + ((CONFIG.lobby && CONFIG.lobby.joinSeconds) || 120) * 1000, + picks: new Map(), // playerId -> { team, role } + activitySessionId: null, + }; + + lobby.activityPromise = Activities.sessions.create(referee, ACTIVITY_ID, { + title: ffa ? `${spec.label} — open` : `${cap1(a)} vs ${cap1(b)}`, + visibility: ffa ? "public" : "unlisted", + metadata: { mode, teams: [a, b], ffa: !!ffa }, + lobbyTtlSeconds: (CONFIG.lobby && CONFIG.lobby.joinSeconds) || 120, + }).then((session) => { + if (LOBBY === lobby) lobby.activitySessionId = session.id; + else void Activities.sessions.cancel(session.id, { resource: RESOURCE, reason: "lobby already closed" }); + return session; + }).catch((error) => logger.warn(`Could not mirror lobby into hmp-activities: ${error}`)); + + // Told only to the two houses playing. announce() would reach the whole session, which is not + // what a fixture between two houses means. A small unfocused card, not the picker: most of the + // people told are busy with something else, and the call has to be something they can ignore. + let called = 0; + for (const p of PlayerManager.getAll()) { + if (!lobbyEligible(p)) continue; + ++called; + const what = ffa ? `${spec.label} — open to all` : `${spec.label}: ${cap1(a)} vs ${cap1(b)}`; + try { p.sendChat(`${referee.nickname || "A referee"} has called ${what}.`); } catch (_) {} + pushLobbyCard(p); + } + try { referee.sendChat(`Called ${ffa ? "open" : cap1(a) + " vs " + cap1(b)} — ${called} player(s) notified.`); } catch (_) {} +} + +const lobbyJoinKey = () => String((CONFIG.lobby && CONFIG.lobby.joinKey) || "j").toLowerCase(); + +/** What one player's call card shows: the fixture, the time left, how full each side is, their pick. */ +function lobbyCardFor(player) { + const colours = CONFIG.houseColours || {}; + const total = Object.values(lineup()).reduce((n, count) => n + count, 0); + const pick = LOBBY.picks.get(player.id); + return { + title: LOBBY.ffa ? "Open match" : `${cap1(LOBBY.teams[0])} vs ${cap1(LOBBY.teams[1])}`, + mode: (CONFIG.modes[LOBBY.mode] || {}).label || LOBBY.mode, + remainingMs: Math.max(0, LOBBY.endsAt - Date.now()), + teams: LOBBY.teams.map((team) => ({ + team: cap1(team), + colour: colours[team] || "#8a8a8a", + filled: Object.values(slotsOf(team)).reduce((n, held) => n + held.length, 0), + total, + })), + pick: pick ? `${cap1(pick.team)} ${cap1(pick.role)}` : null, + referee: player.id === LOBBY.refereeId, + canPlay: !!lobbySideFor(player), + key: lobbyJoinKey().toUpperCase(), + }; +} + +function pushLobbyCard(player) { + if (!LOBBY || !lobbyEligible(player)) return; + try { player.emit("quidditch:lobbyCard", JSON.stringify(lobbyCardFor(player))); } catch (_) {} +} + +// Every eligible card, after anything that changes what they show: a pick, a leave, a departure. +function pushLobbyCards() { + for (const p of PlayerManager.getAll()) pushLobbyCard(p); +} + +function closeLobby(reason, preserveActivity) { + if (!LOBBY) return; + const activitySessionId = LOBBY.activitySessionId; + const audience = PlayerManager.getAll().filter((p) => lobbyEligible(p)); + for (const p of audience) { + try { p.emit("quidditch:lobbyCard", JSON.stringify({ closed: true })); } catch (_) {} + // Only a picker this resource opened. A full close would also cancel any other dialog they had up. + if (lobbyMenus.has(p.id)) { try { Ui.close(p, reason || "Quidditch lobby closed"); } catch (_) {} } + if (reason) { try { p.sendChat(reason); } catch (_) {} } + } + LOBBY = null; + if (activitySessionId && !preserveActivity) { + void Activities.sessions.cancel(activitySessionId, { resource: RESOURCE, reason: reason || "lobby closed" }) + .catch((error) => logger.warn(`Could not cancel activity lobby: ${error}`)); + } +} + +const lobbyMenus = new Set(); + +async function showLobby(player) { + if (!LOBBY || !lobbyEligible(player) || lobbyMenus.has(player.id)) return; + const lobby = LOBBY; + const mySide = lobbySideFor(player); + const options = []; + if (mySide) { + for (const team of lobby.teams) { + if (mySide !== true && mySide !== team) continue; + const slots = slotsOf(team); + for (const [role, maximum] of Object.entries(lineup())) { + const filled = slots[role].length; + if (filled >= maximum) continue; + options.push({ + id: `join:${team}:${role}`, + title: `${cap1(team)} ${cap1(role)}`, + description: `${maximum - filled} of ${maximum} slot(s) open`, + }); + } + } + } + options.push({ id: "pitch", title: "Go to the pitch", description: "Travel to the Quidditch pitch." }); + if (lobby.picks.has(player.id)) options.push({ id: "leave", title: "Leave lineup", tone: "warning" }); + if (player.id === lobby.refereeId) { + options.push({ id: "start", title: "Start now", description: "Begin with the current lineup." }); + options.push({ id: "cancel", title: "Call off match", tone: "warning" }); + } + + lobbyMenus.add(player.id); + let choice = null; + try { + choice = await Ui.context(player, { + title: lobby.ffa ? "Open Quidditch match" : `${cap1(lobby.teams[0])} vs ${cap1(lobby.teams[1])}`, + description: `${(CONFIG.modes[lobby.mode] || {}).label || lobby.mode} · choose a position`, + options, + canClose: true, + }); + } catch (error) { + logger.warn(`Lobby menu failed for ${player.id}: ${error}`); + } finally { + lobbyMenus.delete(player.id); + } + if (!choice || LOBBY !== lobby) return; + if (choice.startsWith("join:")) { + const [, team, role] = choice.split(":"); + claimSlot(player, team, role); + pushLobbyCards(); + return; + } + if (choice === "pitch") { goToPitch(player); return; } + if (choice === "leave") { lobby.picks.delete(player.id); pushLobbyCards(); return; } + if (player.id !== lobby.refereeId) return; + if (choice === "start") { void startFromLobby(true).catch((error) => logger.warn(`Lobby start failed: ${error}`)); return; } + if (choice === "cancel") closeLobby("The match was called off."); +} + +/** Who holds each slot of a team, in lineup order. */ +function slotsOf(team) { + const out = {}; + for (const role of Object.keys(lineup())) out[role] = []; + for (const [id, pick] of LOBBY.picks) { + if (pick.team !== team || !out[pick.role]) continue; + const p = PlayerManager.getAll().find((x) => x.id === id); + out[pick.role].push({ id, name: (p && p.nickname) || `#${id}` }); + } + return out; +} + +/** One team's column: its name, how many have signed, and a row per slot of the lineup. */ +function teamColumn(team, viewer, mySide) { + const slots = slotsOf(team); + const filled = Object.values(slots).reduce((n, a) => n + a.length, 0); + const rows = []; + for (const [role, count] of Object.entries(lineup())) { + for (let i = 0; i < count; i++) { + const who = slots[role][i]; + const label = count > 1 ? `${cap1(role)} ${i + 1}` : cap1(role); + // An open slot is CLICKABLE only on your own side. The other column is a team sheet, not + // a menu — a listrow with no id does not fire, so this is the rule as well as the look. + const yours = mySide === true || mySide === team; + rows.push({ type: "listrow", props: who + ? { title: label, sub: who.name, rightText: who.id === viewer.id ? "you" : "" } + : (yours + ? { id: `join:${team}:${role}`, title: label, sub: "open", rightText: "join" } + : { title: label, sub: "open", rightText: "" }) }); + } + } + return { type: "col", props: { gap: 4 }, children: [ + { type: "eyebrow", props: { text: `${cap1(team)} — ${filled}` } }, + { type: "list", children: rows }, + ] }; +} + +/** + * Wide, not tall: a team down each side and the controls between them. A fixture has two sides and + * the panel should look like it — stacking them turns a match sheet into a scrolling list where the + * other team is somewhere off the bottom. + */ +function lobbyTree(viewer) { + const mine = LOBBY.picks.get(viewer.id); + const mySide = lobbySideFor(viewer); + const isRef = viewer.id === LOBBY.refereeId; + const [a, b] = LOBBY.teams; + + const middle = []; + // No packets: rp-ui rewrites a countdownTo node's text locally on its own tick. + middle.push({ type: "text", props: { text: "", countdownTo: LOBBY.endsAt } }); + middle.push({ type: "text", props: { text: (CONFIG.modes[LOBBY.mode] || {}).label || LOBBY.mode, muted: true } }); + middle.push({ type: "spacer" }); + // Signing up and standing somewhere else is the common case: the lobby is opened from a prop, + // and nothing about claiming a slot moves you. + middle.push({ type: "button", props: { id: "pitch", label: "Go to the pitch" } }); + if (mine) { + middle.push({ type: "button", props: { id: "leave", label: "Leave", variant: "ghost" } }); + } + if (isRef) { + middle.push({ type: "button", props: { id: "start", label: "Start now", variant: "primary" } }); + middle.push({ type: "button", props: { id: "cancel", label: "Cancel", variant: "ghost" } }); + } + if (!mySide) { + middle.push({ type: "text", props: { text: "Refereeing — no slot", muted: true } }); + } + + // `page` with size "wide", not a hand-rolled panel: size is a KIND of surface, not a width, and + // rp-ui turns it into the width, height, gaps and scroll behaviour. rp-casebook's terminal is the + // same call. Naming 75vw here would be re-deciding a house answer in a resource that has no + // business holding an opinion about it. + return { type: "page", props: { + title: "QUIDDITCH", + size: "wide", + eyebrow: LOBBY.ffa ? "OPEN MATCH" : `${cap1(a)} VS ${cap1(b)}`, + }, children: [ + // GRID, not row. A row lays its children out at their content width and leaves the rest of a + // 75vw panel empty on the right; arcanum's grid is repeat(cols, 1fr), so three columns share + // the width and the middle one actually sits in the middle. + { type: "grid", props: { cols: 3, gap: 16 }, children: [ + teamColumn(a, viewer, mySide), + { type: "col", props: { gap: 6 }, children: middle }, + teamColumn(b, viewer, mySide), + ] }, + ] }; +} +/** Claim a slot, giving up whatever was held before. */ +function claimSlot(player, team, role) { + if (!LOBBY) return; + const want = lineup()[role]; + if (!want || !lobbyTeams().includes(team)) return; + // You play for your own house. The other side is visible so the fixture reads as one, but it is + // not yours to fill. + const side = lobbySideFor(player); + if (!side) { try { player.sendChat("You are not in either house."); } catch (_) {} return; } + if (side !== true && team !== side) { + try { player.sendChat(`You play for ${cap1(side)}.`); } catch (_) {} + return; + } + const held = slotsOf(team)[role].filter((s) => s.id !== player.id); + if (held.length >= want) { try { player.sendChat("That position is taken."); } catch (_) {} return; } + LOBBY.picks.set(player.id, { team, role }); + try { Ui.notify(player, { title: "Quidditch", description: `${cap1(team)} ${cap1(role)} selected.`, tone: "success" }); } catch (_) {} +} + +/** Everyone who claimed a slot, as the roster startSession will seat. */ +function lobbyRoster() { + const out = []; + for (const p of PlayerManager.getAll()) { + const pick = LOBBY.picks.get(p.id); + if (pick) out.push({ player: p, team: pick.team, role: pick.role }); + } + return out; +} + +async function startFromLobby(byReferee) { + if (!LOBBY) return; + const lobby = LOBBY; + if (lobby.starting) return; + lobby.starting = true; + await lobby.activityPromise; + if (LOBBY !== lobby) return; + const roster = lobbyRoster(); + if (!roster.length) { + if (byReferee) announce("Nobody has picked a side yet."); + lobby.starting = false; + return; + } + const mode = LOBBY.mode; + const thin = lobbyTeams().some((tm) => + roster.filter((r) => r.team === tm).length < ((CONFIG.lobby && CONFIG.lobby.wantPerTeam) || 2)); + // A warning, never a refusal: a four-a-side scratch match is still a match. + if (thin) announce("Short sides — playing anyway."); + // Capture the fixture BEFORE closing: the ends belong to the two houses that were called, not + // to whichever of them happened to fill a slot. + const fixture = LOBBY.teams.slice(); + const activitySessionId = LOBBY.activitySessionId; + closeLobby(null, true); + startSession(mode, null, true, roster, fixture, activitySessionId); +} + +// The join clock. Starts the match if anybody claimed a slot, and quietly gives up if nobody did. +setInterval(() => { + if (!LOBBY || Date.now() < LOBBY.endsAt) return; + if (LOBBY.picks.size) void startFromLobby(false).catch((error) => logger.warn(`Automatic lobby start failed: ${error}`)); + else closeLobby("Nobody joined — the match was called off."); +}, 1000); + +// ---------------------------------------------------------------- lifecycle + +/** + * Where a player starts. With sides assigned, each lines up in the half it defends — kicking off + * from a shared clump in the middle makes the ends meaningless before the first pass. + */ +function pitchSpawn(index, team) { + const spread = 1200; + const row = Math.floor(index / 4) * 900; + const end = S && S.defends && team && S.defends[team]; + const z = DECK_Z + (P.spawnHeight || 0); + if (!end) return v(CENTRE.x - 3000 + row, CENTRE.y + (index % 4) * spread - spread * 1.5, z); + const g = Object.assign({}, P.hoopGeometry, TUNE); + const a = (g.axisDeg * Math.PI) / 180; + const along = v(Math.cos(a), Math.sin(a), 0); + const across = v(-Math.sin(a), Math.cos(a), 0); + const back = add(CENTRE, scale(along, end * (g.goalDistance * 0.55))); + const lateral = (index % 4) * spread - spread * 1.5; + const flat = add(add(back, scale(across, lateral)), scale(along, end * row * 0.5)); + return v(flat.x, flat.y, z); +} + +/** + * Everything a seat needs to start playing: a broom, a place to stand, the brief its client + * builds the pitch from, and its HUD. + * + * ONE copy, called by both kickoff and rejoin. Two copies of this would drift, and the drift + * would look like 'rejoining works but the ball does not attach to your hand' rather than like + * a missing line. + * + * One try/catch around all of it once cost an entire session: the broom grant threw, and the + * teleport and the brief died with it while the snitch kept broadcasting from another path. + * Independent steps get independent failures, and failures get logged. + */ +/** Names are the only handle that survives a reconnect. Lower-cased so case drift cannot lose a seat. */ +const nameKey = (player) => String((player && player.nickname) || "").trim().toLowerCase(); + +/** Sides in play: the fixture's if there is one, otherwise whoever is actually seated. */ +function sidesNow() { + if (S && S.defends) return Object.keys(S.defends); + return [...new Set(seats().map((x) => x.team).filter(Boolean))]; +} + +/** Drop holds that have run out, so one abandoned seat cannot block a side for the whole match. */ +function pruneVacated() { + if (!S) return; + const now = Date.now(); + for (const [key, held] of S.vacated) if (held.until <= now) S.vacated.delete(key); +} + +/** The first position on this side that is neither filled nor being held, or null. */ +function openRole(team) { + const want = lineup(); + const taken = {}; + for (const seat of seats()) if (seat.team === team) taken[seat.role] = (taken[seat.role] || 0) + 1; + for (const held of S.vacated.values()) if (held.team === team) taken[held.role] = (taken[held.role] || 0) + 1; + for (const role of Object.keys(want)) if ((taken[role] || 0) < want[role]) return role; + return null; +} + +/** + * Take a seat in a session that is already running. + * + * A returning player gets their OWN seat back -- same side, same position, same goals scored -- + * because losing those to a crash is the thing this exists to stop. Everyone else takes an open + * slot: their own house if it is playing and has room, otherwise the shorter side. + */ +function joinSession(player) { + // A CALLED match is a lobby, not a session: `S` does not exist until it starts. Asking to join + // one is asking for the position picker, so hand over the panel rather than the flat denial + // this used to give -- "No match is running" is a lie when somebody just called one. + if (!S) { + if (!LOBBY) { try { player.sendChat("No match is running. /quidditch call "); } catch (_) {} return false; } + if (!lobbyEligible(player)) { + // Silence here is the expensive kind: the panel simply never appears and nothing says why. + const who = LOBBY.teams.map(cap1).join(" vs "); + try { player.sendChat(`This match is ${who}. You are not in either house.`); } catch (_) {} + return false; + } + showLobby(player); + return false; + } + if (S.players.has(player.id)) return false; + pruneVacated(); + + const key = nameKey(player); + const held = S.vacated.get(key); + let team = held && held.team; + let role = held && held.role; + + if (!held) { + const sides = sidesNow(); + if (!sides.length) { try { player.sendChat("This match has no sides to join."); } catch (_) {} return false; } + const mine = playerHouse(player); + // Your own house first if it is playing, then the shorter side -- joining the side that is + // already winning on numbers is nobody's idea of fair. + const order = sides.includes(mine) + ? [mine, ...sides.filter((x) => x !== mine)] + : [...sides].sort((a, b) => seats().filter((x) => x.team === a).length + - seats().filter((x) => x.team === b).length); + for (const side of order) { + const free = openRole(side); + if (free) { team = side; role = free; break; } + } + if (!team) { + try { player.sendChat("Every position is taken."); } catch (_) {} return false; + } + } + + const seat = { + player, team, role, + lastPos: null, + // Their match, not a new one: a crash should not wipe the goals they already scored. + stats: (held && held.stats) || { goals: 0, snitch: 0 }, + }; + S.players.set(player.id, seat); + S.vacated.delete(key); + if (S.activitySessionId) { + void Activities.sessions.join(player, S.activitySessionId) + .catch((error) => logger.warn(`Could not add ${player.id} to activity session: ${error}`)); + } + + briefSeat(seat, S.players.size - 1); + pushScore(); // everyone: the bar's sides can change when a seat fills + announce(`${player.nickname || "A player"} ${held ? "returns" : "joins"} for ${cap1(team)} at ${role}.`); + return true; +} + +/** + * Do they already carry one? InventoryItem's field is `itemId`, not `id` -- filtering on the wrong + * name silently counts zero of everything, which here would read as 'they have no broom' forever + * and hand out a fresh one every single match. + */ +function hasBroom(player) { + try { + return (Inventory.native.list(player) || []) + .some((row) => row.itemId === CONFIG.broomItem && (row.count || 0) > 0); + } + catch (_) { return false; } // unreadable inventory: grant, since a spare beats none +} + +/** On the deck at the centre, fanned a little by id so a crowd does not arrive inside itself. */ +function pitchArrival(player) { + const lane = ((player && player.id) || 0) % 5; + return v(CENTRE.x + (lane - 2) * 300, CENTRE.y, DECK_Z + (P.spawnHeight || 0)); +} + +/** Take me there. The lobby's button and /quidditch pitch are the same call. */ +function goToPitch(player) { + const at = pitchArrival(player); + try { player.teleport(at.x, at.y, at.z); } + catch (err) { console.error(`[QUIDDITCH] teleport to pitch failed for ${player.id}: ${err}`); return false; } + if (!hasBroom(player)) { + void Inventory.native.give(player, CONFIG.broomItem, 1) + .catch((error) => logger.warn(`Broom grant failed for ${player.id}: ${error}`)); + } + try { player.emit("quidditch:notice", JSON.stringify({ text: "The pitch. Mount your broom to fly." })); } catch (_) {} + return true; +} + +function briefSeat(seat, i) { + const spawn = pitchSpawn(i, seat.team); + const step = (what, fn) => { + try { fn(); return true; } + catch (err) { console.error(`[QUIDDITCH] ${what} failed for ${seat.player.id}: ${err}`); return false; } + }; + + // One broom per player, not one per match. Granting unconditionally handed out a fresh broom + // every kickoff and the resource never takes any of them back. + step("broom grant", () => { + if (!hasBroom(seat.player)) { + void Inventory.native.give(seat.player, CONFIG.broomItem, 1) + .catch((error) => logger.warn(`Broom grant failed for ${seat.player.id}: ${error}`)); + } + }); + step("teleport", () => seat.player.teleport(spawn.x, spawn.y, spawn.z)); + const briefed = step("setup", () => seat.player.emit("quidditch:setup", JSON.stringify({ + mode: S.mode, + playerId: seat.player.id, + role: seat.role, + team: seat.team, + centre: CENTRE, + ball: CONFIG.ball, + ballId: S.ball.obj ? S.ball.obj.id : 0, // the quaffle's WorldObject, so a carrier knows it holds it + bludgers: CONFIG.bludgers, + // Beaters carry the bat, and now they are the only ones who may use it. + bat: seat.role === "beater" ? batFor(CONFIG.bludgers.bat) : "", + batSocket: CONFIG.bludgers.batSocket || "WandSocket", + batOffset: CONFIG.bludgers.batOffset || {}, + unlockCasting: !!CONFIG.unlockMountedCasting, + liftLimits: !!CONFIG.liftFlightLimits, + ceiling: CENTRE.z + P.envelope.up, + }))); + if (!briefed) { + try { seat.player.sendChat("Quidditch: could not brief your client — see the server log."); } catch (_) {} + } + step("hud", () => pushHud(seat)); +} +function startSession(mode, starter, solo, roster, fixture, activitySessionId) { + const spec = CONFIG.modes[mode]; + if (!spec) { starter.sendChat(`Unknown mode: ${mode}`); return; } + if (S) { + starter.sendChat("A session is already running."); + try { starter.emit("quidditch:notice", JSON.stringify({ text: "A session is already running — Stop it first." })); } catch (_) {} + return; + } + + const lineupRoster = roster || null; + const connected = PlayerManager.getAll(); + if (!solo && connected.length < (spec.minPlayers || 1)) { + starter.sendChat(`${spec.label} needs ${spec.minPlayers} player(s) — add "solo" to force it: /quidditch start ${mode} solo`); + try { starter.emit("quidditch:notice", JSON.stringify({ text: `${spec.label} needs ${spec.minPlayers} players. Turn Solo on.` })); } catch (_) {} + return; + } + + S = freshSession(mode); + S.activitySessionId = activitySessionId || null; + // A lobby roster already carries team and role; without one, fall back to seating whoever is + // connected, which is what solo testing needs. + const seating = lineupRoster + ? lineupRoster.map((r) => ({ player: r.player, team: r.team, role: r.role })) + : connected.map((player) => ({ player, team: null, role: null })); + seating.forEach((entry, i) => { + const player = entry.player; + const seat = { + player, + // A lobby pick wins outright — it is what the players agreed. Otherwise: an explicit + // /quidditch team, then their real house, then a round-robin for Unaffiliated characters. + team: entry.team || pendingTeams.get(player.id) || playerHouse(player) + || HOUSES[i % (mode === "match" ? 2 : HOUSES.length)], + role: entry.role || pendingRoles.get(player.id) + || (mode === "snitchHunt" ? "seeker" : CONFIG.roles[i % CONFIG.roles.length]), + lastPos: null, + stats: { goals: 0, snitch: 0 }, // this match only; the store keeps the career totals + }; + S.players.set(player.id, seat); + }); + + // hmp-activities owns discovery/exclusivity and lifecycle. The gameplay simulation remains + // authoritative here, while the shared activity record mirrors its participants and state. + void (async () => { + let id = S && S.activitySessionId; + if (!id && starter) { + const activity = await Activities.sessions.create(starter, ACTIVITY_ID, { + title: `${spec.label} session`, visibility: "unlisted", metadata: { mode }, + }); + id = activity.id; + if (S) S.activitySessionId = id; + } + if (!id) return; + const current = Activities.sessions.get(id); + const joined = new Set((current && current.participants || []).map((p) => p.playerId)); + for (const entry of seating) { + if (!joined.has(entry.player.id)) { + try { await Activities.sessions.join(entry.player, id); } catch (error) { logger.warn(`Activity join failed for ${entry.player.id}: ${error}`); } + } + } + await Activities.sessions.start(id, { resource: RESOURCE, actor: starter || undefined, reason: "match started" }); + })().catch((error) => logger.warn(`Could not start hmp-activities session: ${error}`)); + + // A match needs two sides, and real houses do not guarantee that — six Gryffindors is not a + // fixture. Only reassign when the roster genuinely collapsed onto one house, and never override + // someone who picked a side explicitly. + // + // Two SIDES need two PLAYERS. One seat is not a collapsed roster, it is one person, and there + // is nobody to split them from -- but this fired anyway and overwrote their real house with + // HOUSES[0]. Catch the snitch as a Hufflepuff, and Gryffindor was announced. + // + // And when it does fire it splits along the FIXTURE. A called Hufflepuff-vs-Slytherin match + // that everyone turned up to in the same robes is still that fixture; sorting those players + // into Gryffindor and Slytherin because that is the order HOUSES happens to be written in + // renames the match out from under the people who called it. + const called = fixture && fixture.length === 2 && fixture[0] !== fixture[1] ? fixture : HOUSES; + if (mode === "match" && seats().length > 1 && new Set(seats().map((s) => s.team)).size < 2) { + seats().forEach((seat, i) => { + if (!pendingTeams.has(seat.player.id)) seat.team = called[i % 2]; + }); + announce(`Everyone sorted into one house — split into ${cap1(called[0])} and ${cap1(called[1])}.`); + } + + { + // Count is the switch, not the mode. Bludgers were gated to `match`, which needs two + // players, so in practice they had never been in play at all. + const nBludgers = mode === "practice" ? 0 : (bludgerCount ?? CONFIG.bludgers.count); + for (let i = 0; i < nBludgers; i++) { + S.bludgers.push({ pos: add(CENTRE, v(0, 0, 2500)), vel: v(1, 0, 0), targetId: null }); + } + S.bludgers.forEach(placeBludger); + } + S.ball.obj = spawnQuaffle(QUAFFLE_KEY, S.ball.pos); + placeHoops(); + + // One try/catch around all of this once cost an entire session: the broom grant threw, and the + // teleport and the setup brief died with it while the snitch kept broadcasting from another + // path. Independent steps get independent failures, and failures get logged. + // Armed BEFORE the briefs go out. A mode that starts with the snitch already out then carries + // `snitch: true` in its setup payload and each client spawns it once from there; arming after + // meant the payload said there was none and a second event had to put it back. + if (S.mode !== "practice") { + const after = Number(spec.releaseAfterSec ?? CONFIG.snitch.releaseAfterSec ?? 0) || 0; + if (after <= 0) releaseSnitch(); + else { + S.snitch.releaseAt = S.startedAt + after * 1000; + announce(`The Snitch is released in ${Math.round(after / 60)} minute(s).`); + } + } + + seats().forEach(briefSeat); + + // Which side keeps which end. A hoop only means something once somebody owns it. + // + // The FIXTURE decides this when there is one, not who turned up: a called Gryffindor-vs-Slytherin + // match has two ends even if only Gryffindor filled a slot. Falling back to the distinct teams + // actually seated left a solo session with one side, no ends, and therefore no banners — which is + // exactly how this went unnoticed. + { + const seated = [...new Set(seats().map((s) => s.team))]; + let pair = null; + if (fixture && fixture.length === 2 && fixture[0] !== fixture[1]) pair = fixture; + else if (seated.length === 2) pair = seated; + else if (seated.length === 1) { + // One side on the pitch still gets an end to attack, or a solo test cannot score at all. + const other = HOUSES.find((h) => h !== seated[0]); + if (other) pair = [seated[0], other]; + } + if (pair) { + S.defends = { [pair[0]]: 1, [pair[1]]: -1 }; + announce(`${cap1(pair[0])} defend one end, ${cap1(pair[1])} the other.`); + } + else { + console.log(`[QUIDDITCH] no ends assigned (${seated.length} side(s)) — every hoop scores`); + } + } + + pushBanners(); + startAmbience(); + announce(`${spec.label} — begin!`); + publishScoreboard(); + pushScore(); // after the ends are assigned, so left and right match the pitch + pendingTeams.clear(); + pendingRoles.clear(); +} + +/** + * End the session, but let the moment that ended it play first. + * + * Catching the snitch used to tear everything down in the same tick that announced it: the banner, + * the final score and the snitch despawn all landed together, and teardown destroyed the client's + * views a few milliseconds into the biggest banner in the game. A celebration window fixes that at + * the source rather than making the overlay outlive its session. + * + * The world stays up during the window and only scoring stops — see the tick. + */ +function finishSession(reason, delayMs) { + if (!S || S.ending) return; + if (!delayMs) { endSession(reason); return; } + S.ending = true; + // A catch has already cheered a beat ago; anything else ending has not, and a match that just + // runs out of clock deserves the same crowd as one that is won outright. + if (reason !== "snitch") sfxAll(SFX.victory); + sfxAll(SFX.finish); + broadcast("quidditch:results", resultsPayload(reason)); + setTimeout(() => { if (S && S.ending) endSession(reason); }, delayMs); +} + +/** Final score plus who did what. Career totals live in the store; these are this match only. */ +function resultsPayload(reason) { + const goalPts = CONFIG.scoring.goal, snitchPts = CONFIG.snitch.points; + const players = seats().map((s) => { + const st = s.stats || { goals: 0, snitch: 0 }; + return { + name: s.player.nickname || `#${s.player.id}`, + team: s.team, role: s.role, goals: st.goals, snitch: st.snitch, + points: st.goals * goalPts + st.snitch * snitchPts, + }; + }).sort((a, b) => b.points - a.points || a.name.localeCompare(b.name)); + + // Only houses that actually fielded someone — an empty Ravenclaw row on a solo hunt is noise. + const played = new Set(seats().map((s) => s.team)); + const score = Object.entries(S.score) + .filter(([house, pts]) => played.has(house) || pts > 0) + .sort((a, b) => b[1] - a[1]) + .map(([house, pts]) => ({ house, points: pts })); + + return { + reason, mode: (CONFIG.modes[S.mode] || {}).label || S.mode, + winner: score.length && score[0].points > 0 ? score[0].house : null, + durationSec: Math.round((Date.now() - S.startedAt) / 1000), + score, players, + }; +} + +function endSession(reason) { + if (!S) return; + const activitySessionId = S.activitySessionId; + const activityResult = resultsPayload(reason); + for (const id of [...stunCards.keys()]) clearStunCard(id); + for (const h of fumbleCards.values()) clearInterval(h); + fumbleCards.clear(); + stopAmbience(); + placeBanners([]); + clearBludgers(); + clearQuaffle(); + clearHoopObjects(); + clearSnitch(); + S.finished = true; + const winner = Object.entries(S.score).sort((a, b) => b[1] - a[1])[0]; + announce(`Session over (${reason}). ${winner && winner[1] > 0 ? `${winner[0]} take it with ${winner[1]}.` : "No score."}`); + for (const seat of seats()) { + try { seat.player.emit("quidditch:teardown", JSON.stringify({})); } catch (_) {} + if (Events.listenerCount("rp:ui:hud:clear") > 0) { + try { Events.emit("rp:ui:hud:clear", seat.player, "quidditch"); } catch (_) {} + } + } + S = null; + FoundationAudio.sounds.release(AUDIO_OWNER); + clearMatchBlips(); + if (activitySessionId) { + const terminal = reason === "snitch" || reason === "time"; + const operation = terminal + ? Activities.sessions.complete(activitySessionId, activityResult, { resource: RESOURCE, reason }) + : Activities.sessions.cancel(activitySessionId, { resource: RESOURCE, reason }); + void operation.catch((error) => logger.warn(`Could not close hmp-activities session: ${error}`)); + } + publishScoreboard(); +} + +// The catch bar rides its own beat rather than the 500 ms HUD refresh — a 1.2 s hold needs to be +// seen filling, and the grace decay needs to be seen draining when you drift off. +let catchCardOn = null; // playerId currently showing the bar + +function pushCatchBar() { + if (Events.listenerCount("rp:ui:hud") === 0 || !S) return; + const sn = S.snitch; + const holder = sn.holder ? S.players.get(sn.holder) : null; + + if (catchCardOn && (!holder || catchCardOn !== sn.holder)) { + const stale = S.players.get(catchCardOn); + if (stale) { try { Events.emit("rp:ui:hud", stale.player, "quidditch:catch", null); } catch (_) {} } + catchCardOn = null; + } + if (!holder) return; + + const need = CONFIG.snitch.catchHoldMs || 1200; + const held = clamp(sn.heldMs, 0, need); + try { + Events.emit("rp:ui:hud", holder.player, "quidditch:catch", { + kind: "meter", label: "Catching", value: Math.round(held), max: need, order: 25, + tone: "gold", detail: `${((need - held) / 1000).toFixed(1)}s`, + }); + catchCardOn = sn.holder; + } catch (_) {} +} + +function clearCatchBar() { + if (!catchCardOn || !S) { catchCardOn = null; return; } + const seat = S.players.get(catchCardOn); + if (seat) { try { Events.emit("rp:ui:hud", seat.player, "quidditch:catch", null); } catch (_) {} } + catchCardOn = null; +} + +/** + * What to press, while carrying. Pass and Shoot have no world anchor — they are things you do with + * the ball, not things you walk up to — so they cannot ride a world plate the way pickup does, and + * the HUD is the only surface left. The key is not named: rp-ui owns the keymap and the wheel key is + * configurable, so spelling one out here would go stale silently. + * + * Edge-triggered. `pushHud` runs twice a second and re-emitting an unchanged card that often is + * pointless work in the HUD arbiter. + */ +/** A bat id from the config list, or the configured default. Unknown ids fall back rather than throw. */ +function batFor(id) { + const list = (CONFIG.bludgers && CONFIG.bludgers.bats) || []; + const want = id || CONFIG.bludgers.bat; + // Case-insensitive: the chat command lower-cases its argument, so a camelCase id like `shopA` + // would never match itself and every shop bat read as an unknown name. + const key = String(want || "").toLowerCase(); + const hit = list.find((b) => String(b.id).toLowerCase() === key) || list[0]; + return hit ? hit.mesh : ""; +} + +function pushBat(player, id) { + const b = CONFIG.bludgers || {}; + try { + player.emit("quidditch:bat", JSON.stringify({ + mesh: id === "" ? "" : batFor(id), socket: b.batSocket, offset: b.batOffset || {}, + })); + } catch (_) {} +} + +/** + * A bludger hit takes your broom input for a moment, and until now nothing said so — control simply + * stopped answering, which reads as a hitch or a hang rather than a hit. Rides its own beat like the + * catch bar, because a 1.8 s window has to be seen draining to be understood. + */ +let stunCards = new Map(); // playerId -> interval handle + +function pushStunCard(seat, now) { + if (Events.listenerCount("rp:ui:hud") === 0) return; + const id = seat.player.id; + const until = now + CONFIG.bludgers.stunMs; + const draw = () => { + const left = until - Date.now(); + if (left <= 0 || !S) { clearStunCard(id); return; } + try { + Events.emit("rp:ui:hud", seat.player, "quidditch:stun", { + kind: "meter", label: "Knocked", value: Math.round(left), max: CONFIG.bludgers.stunMs, + order: 22, tone: "red", detail: `${(left / 1000).toFixed(1)}s`, + }); + } catch (_) {} + }; + const prev = stunCards.get(id); + if (prev) clearInterval(prev); + stunCards.set(id, setInterval(draw, 100)); + draw(); +} + +function clearStunCard(id) { + const h = stunCards.get(id); + if (h) clearInterval(h); + stunCards.delete(id); + const seat = S && S.players.get(id); + if (seat) { try { Events.emit("rp:ui:hud", seat.player, "quidditch:stun", null); } catch (_) {} } +} + +function pushCarryCard(seat) { + if (Events.listenerCount("rp:ui:hud") === 0 || !S) return; + const carrying = S.ball.carrier === seat.player.id; + if (carrying === !!seat.carryCard) return; + seat.carryCard = carrying; + try { + Events.emit("rp:ui:hud", seat.player, "quidditch:quaffle", carrying + ? { kind: "badge", label: "Quaffle", tone: "gold", order: 15, + detail: `${PASS_KEY.toUpperCase()} pass · ${SHOOT_KEY.toUpperCase()} shoot` } + : null); + } catch (_) {} +} + +function pushHud(seat) { + if (Events.listenerCount("rp:ui:hud") === 0 || !S) return; + pushCarryCard(seat); + try { + Events.emit("rp:ui:hud", seat.player, "quidditch:score", + { kind: "badge", label: `${S.score[seat.team] || 0}`, tone: "gold", order: 10, detail: seat.team }); + Events.emit("rp:ui:hud", seat.player, "quidditch:clock", + { kind: "timer", label: CONFIG.modes[S.mode].label, startedAt: S.startedAt, endsAt: S.endsAt, order: 20 }); + // Before release there is nothing to be near, so the bar is a clock instead. Without this + // the HUD says nothing at all for five minutes and the snitch reads as broken. + if (S.mode !== "practice" && !S.snitch.releasedAt && S.snitch.releaseAt) { + Events.emit("rp:ui:hud", seat.player, "quidditch:snitch", + { kind: "timer", label: "Snitch released", startedAt: S.startedAt, + endsAt: S.snitch.releaseAt, order: 30 }); + } + if (S.mode !== "practice" && S.snitch.releasedAt && S.snitch.pos && seat.lastPos && !S.snitch.hidden) { + // Proximity as a percentage, closing over the last `hudRange` metres. The bar used to + // carry raw inverted centimetres, which is not a number anyone can read. + const d = catchDistance(seat); + const range = (CONFIG.snitch.hudRange || 100) * 100; + const close = Math.round(Math.max(0, 1 - d / range) * 100); + const now = Date.now(); + const worn = Math.round(snitchWear(now) * 100); + const inRange = d <= catchRadiusNow(now); + Events.emit("rp:ui:hud", seat.player, "quidditch:snitch", { + kind: "meter", label: "Snitch", value: close, max: 100, order: 30, + detail: `${Math.round(d / 100)} m${inRange ? " — CATCHING" : (worn > 5 ? ` · tiring ${worn}%` : "")}`, + tone: inRange ? "gold" : undefined, + }); + } + } catch (_) {} +} + +// ---------------------------------------------------------------- tick + +let hudBeat = 0; + +setInterval(() => { + if (!S) return; + const now = Date.now(); + + for (const seat of seats()) { + try { const p = seat.player.position; seat.lastPos = v(p.x, p.y, p.z); } catch (_) {} + } + + // Celebration window: the pitch stays exactly as it is and the HUD keeps showing the final + // score, but nothing can score again — a goal landing during the snitch banner would be absurd. + if (S.ending) { + if (++hudBeat % 10 === 0) { for (const seat of seats()) pushHud(seat); } + return; + } + + // Any step can end the session under us — catching the snitch does exactly that — so the + // session is re-checked between them rather than assumed to survive the tick. + if (S.mode !== "practice") { + // Released from the tick, not a setTimeout: a timer would keep its own reference to a + // session that may already have ended. + if (!S.snitch.releasedAt && S.snitch.releaseAt && now >= S.snitch.releaseAt) releaseSnitch(); + if (S.snitch.releasedAt) { stepSnitch(now); if (!S) return; pushCatchBar(); } + } + if (!S) return; + stepBall(); + if (!S) return; + stepBludgers(now); + if (!S) return; + + // Poke rp-ui when a player CROSSES the reach boundary, so the offer (and the hotkey bind behind + // it) rebuilds the moment the ball comes within range instead of up to 750 ms later. + // + // Edge-triggered, and that is not a micro-optimisation. rp-ui keeps the last 40 offers and a + // client's held hotkey carries the seq of the build it was told about; it only re-learns that + // seq when the map changes or every 7.5 s. Poking on every tick built offers at ~7/s, which cut + // the ring from ~30 s of history down to ~5 s — so the seq the client was holding got evicted + // before it was reissued, and every press in that window was rejected as "not offered". A + // continuous poke is exactly the abuse the 40-offer ring was sized against. + // Poke rp-ui when a player crosses into reach of ANYTHING it offers for. The wheel rebuilds + // context on a 750 ms poll, and a bludger closing at speedMax covers ~19 m in that window — + // wider than the swing reach itself, so without this the Swing offer could arrive after the + // bludger had already passed, exactly as the quaffle pickup did before. + if (Events.listenerCount("rp:ui:context:poke") > 0) { + const ballReach = (CONFIG.ball.pickupRadius || 700) * 2; + const swingReach = (CONFIG.bludgers.swingRadius || 1400) * 1.5; + for (const seat of seats()) { + if (!seat.lastPos) continue; + // Role-gated too: poking for an offer this seat can never be given spends the ring on + // nothing, and the ring is the thing a held hotkey's seq lives in. + const nearBall = S.ball.loose && dist(seat.lastPos, S.ball.pos) <= ballReach + && roleAllowed(seat, CONFIG.ball.carryRoles); + const nearBludger = roleAllowed(seat, CONFIG.bludgers.swingRoles) + && S.bludgers.some((b) => dist(seat.lastPos, b.pos) <= swingReach); + // Edge-triggered on EITHER, because a continuous poke starves rp-ui's 40-offer ring and + // evicts the seq a held hotkey is carrying. + if (nearBall === !!seat.nearBall && nearBludger === !!seat.nearBludger) continue; + seat.nearBall = nearBall; + seat.nearBludger = nearBludger; + try { Events.emit("rp:ui:context:poke", seat.player); } catch (_) {} + } + } + + if (++hudBeat % 10 === 0) { for (const seat of seats()) pushHud(seat); syncMatchBlips(); } + if (now >= S.endsAt) finishSession("time", CONFIG.scoring.celebrateMs ?? 14000); +}, TICK_MS); + +// ---------------------------------------------------------------- client events + +Events.onClient("quidditch:snitchCmd", async (player, raw) => { + if (!(await requireManager(player))) return; + snitchCommand(player, payload(raw)); +}); + +// Shared by the client event and the manager's snitch controls. +function snitchCommand(player, d) { + if (!d || !S) return; + const sn = S.snitch; + if (d.freeze !== undefined) { + sn.frozen = !!d.freeze; + announce(sn.frozen ? "Snitch held in place." : "Snitch released."); + } + if (d.bring) { + const seat = seatOf(player); + if (seat && seat.lastPos) { + sn.pos = add(seat.lastPos, v(0, 0, 300)); + sn.target = sn.pos; + sn.beat = 0; // force an immediate steer so the puppet follows it there + try { player.sendChat("Snitch brought to you."); } catch (_) {} + } + } + if (d.reset) { + sn.caught = false; sn.holder = null; sn.heldMs = 0; sn.frozen = false; + if (sn.releasedAt && !sn.obj) { + spawnSnitch(); + broadcast("quidditch:resnitch", { id: sn.obj ? sn.obj.id : 0 }); + } + } + if (d.release) releaseSnitch(); + if (d.blip !== undefined) { + CONFIG.snitch.blip = !!d.blip; + syncMatchBlips(); + for (const seat of seats()) { + try { seat.player.emit("quidditch:snitchBlip", JSON.stringify({ on: CONFIG.snitch.blip })); } catch (_) {} + } + } +} + +Events.onClient("quidditch:manageStart", async (player, raw) => { + if (!(await requireManager(player))) return; + const d = payload(raw); + if (!d) return; + if (d.stop) { endSession("manager"); return; } + // Call opens the lobby instead of starting immediately — the referee still presses Start. + if (d.call) { openLobby(d.mode || "match", player, d.teamA, d.teamB, !!d.ffa); return; } + if (LOBBY) { void startFromLobby(true).catch((error) => logger.warn(`Lobby start failed: ${error}`)); return; } + startSession(d.mode || "snitchHunt", player, !!d.solo); +}); + +Events.onClient("quidditch:pickup", (player) => { + const seat = seatOf(player); + if (!seat || !S || !S.ball.loose || !seat.lastPos) return; + if (dist(seat.lastPos, S.ball.pos) > (CONFIG.ball.pickupRadius || 700)) return; + if (S.stunned.get(player.id) > Date.now()) return; + grantPossession(seat); +}); + +Events.onClient("quidditch:throw", (player, raw) => { + const seat = seatOf(player); + if (!seat || !S || S.ball.carrier !== player.id) return; + const aim = payload(raw); + if (!aim || typeof aim.x !== "number") return; + + // The client supplies aim only; the server re-derives the launch from its own positions and + // clamps the speed, so a forged payload cannot produce an impossible throw. + const from = add(seat.lastPos, v(0, 0, 60)); + const dir = norm(v(aim.x, aim.y, aim.z)); + const speed = clamp(Number(aim.speed) || CONFIG.ball.throwSpeed, 0, CONFIG.ball.throwSpeedMax); + S.ball.carrier = null; + S.ball.loose = true; + S.ball.lastThrower = player.id; + S.ball.throwFrom = from; // the shot's release point, for the scoring-area rule + S.ball.pos = from; + S.ball.prev = from; + if (S.ball.obj) { + S.ball.obj.detach(); + S.ball.obj.setVelocity(dir.x * speed, dir.y * speed, dir.z * speed); + } + pushScore(); + broadcast("quidditch:ballThrow", {}); +}); + + +Events.onClient("quidditch:block", (player, raw) => { + const seat = seatOf(player); + if (seat && S) blockBall(seat, payload(raw)); +}); + +Events.onClient("quidditch:swing", (player, raw) => { + const seat = seatOf(player); + if (seat && S) swingAt(seat, payload(raw)); +}); + +Events.onClient("quidditch:tackle", (player) => { + const seat = seatOf(player); + if (seat && S) tackle(seat); +}); + +// ---------------------------------------------------------------- manager + +/** + * The tuning screens, on hmp-ui's live input dialogs. + * + * Values live HERE, not in the dialog. Each slider edit reaches the server as it happens, already + * checked against the slider's own range, and lands in the same config box the match reads from. + * Cancelling puts back what the dialog opened with. + */ +const MGR_TABS = [ + { id: "snitch", label: "Snitch" }, + { id: "bludgers", label: "Bludgers" }, + { id: "keeper", label: "Keeper" }, + { id: "bat", label: "Bat" }, + { id: "banners", label: "Banners" }, + { id: "hoops", label: "Hoops" }, +]; + +// Every slider in one table: which section it belongs to, where its value lives, and its range. +// `get` reads the live value; `set` is the only thing that knows where a number goes. +const MGR_SLIDERS = [ + { tab: "snitch", id: "catchHoldMs", label: "Catch hold", min: 200, max: 4000, step: 100, unit: "ms", box: () => CONFIG.snitch }, + { tab: "snitch", id: "catchRadius", label: "Catch radius", min: 300, max: 4000, step: 100, unit: "cm", box: () => CONFIG.snitch }, + { tab: "snitch", id: "cruiseSpeed", label: "Cruise speed", min: 200, max: 3000, step: 50, unit: "", box: () => CONFIG.snitch }, + { tab: "snitch", id: "fleeSpeed", label: "Flee speed", min: 200, max: 4000, step: 50, unit: "", box: () => CONFIG.snitch }, + { tab: "snitch", id: "graceSec", label: "Fatigue after", min: 0, max: 300, step: 5, unit: "s", box: () => CONFIG.snitch.fatigue }, + { tab: "snitch", id: "rampSec", label: "Fatigue ramp", min: 10, max: 600, step: 10, unit: "s", box: () => CONFIG.snitch.fatigue }, + { tab: "snitch", id: "floor", label: "Fatigue floor", min: 0, max: 1, step: 0.05, unit: "x", box: () => CONFIG.snitch.fatigue }, + { tab: "snitch", id: "radiusBonus", label: "Tired catch +", min: 0, max: 3000, step: 100, unit: "cm", box: () => CONFIG.snitch.fatigue }, + + { tab: "bludgers", id: "scale", label: "Scale", min: 0.5, max: 6, step: 0.1, unit: "x", box: () => CONFIG.bludgers, after: pushBludgerScale }, + { tab: "bludgers", id: "speedMin", label: "Speed min", min: 200, max: 3000, step: 50, unit: "", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "speedMax", label: "Speed max", min: 400, max: 5000, step: 50, unit: "", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "accel", label: "Accel", min: 0, max: 2000, step: 50, unit: "", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "homingStrength", label: "Homing", min: 0.02, max: 1, step: 0.02, unit: "", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "hitRadius", label: "Hit radius", min: 20, max: 1200, step: 20, unit: "cm", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "stunMs", label: "Stun", min: 0, max: 12000, step: 100, unit: "ms", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "hitImmuneMs", label: "Immune for", min: 500, max: 40000, step: 500, unit: "ms", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "swingRadius", label: "Swing reach", min: 300, max: 4000, step: 100, unit: "cm", box: () => CONFIG.bludgers }, + { tab: "bludgers", id: "swingSpeed", label: "Swing power", min: 500, max: 6000, step: 100, unit: "", box: () => CONFIG.bludgers }, + + { tab: "keeper", id: "scoringArea", label: "Scoring area", min: 500, max: 6000, step: 100, unit: "cm", box: () => CONFIG.keeper }, + { tab: "keeper", id: "tackleRadius", label: "Tackle reach", min: 200, max: 2500, step: 50, unit: "cm", box: () => CONFIG.keeper }, + { tab: "keeper", id: "fumbleMs", label: "Fumble for", min: 0, max: 30000, step: 500, unit: "ms", box: () => CONFIG.keeper }, + { tab: "keeper", id: "tackleStunMs", label: "Tackle stun", min: 0, max: 3000, step: 100, unit: "ms", box: () => CONFIG.keeper }, + { tab: "keeper", id: "blockRadius", label: "Block reach", min: 200, max: 3000, step: 100, unit: "cm", box: () => CONFIG.keeper }, + { tab: "keeper", id: "blockSpeed", label: "Block power", min: 500, max: 6000, step: 100, unit: "", box: () => CONFIG.keeper }, + { tab: "keeper", id: "minShotDistance", label: "Min shot", min: 0, max: 6000, step: 100, unit: "cm", box: () => CONFIG.keeper }, + + { tab: "bat", id: "pitch", label: "Pitch", min: -180, max: 180, step: 5, unit: "deg", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "yaw", label: "Yaw", min: -180, max: 180, step: 5, unit: "deg", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "roll", label: "Roll", min: -180, max: 180, step: 5, unit: "deg", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "x", label: "Fwd", min: -50, max: 50, step: 1, unit: "cm", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "y", label: "Side", min: -50, max: 50, step: 1, unit: "cm", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "z", label: "Up", min: -50, max: 50, step: 1, unit: "cm", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + { tab: "bat", id: "scale", label: "Scale", min: 0.2, max: 3, step: 0.1, unit: "x", box: () => CONFIG.bludgers.batOffset, after: pushBatToAll }, + + { tab: "banners", id: "yaw", label: "Big yaw", min: 0, max: 350, step: 10, unit: "deg", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "lift", label: "Big lift", min: -2000, max: 4000, step: 50, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "scale", label: "Big scale", min: 0.5, max: 8, step: 0.1, unit: "x", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "side", label: "Big side", min: -1500, max: 1500, step: 25, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "fwd", label: "Big fwd", min: -1500, max: 1500, step: 25, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "hoopYaw", label: "Ring yaw", min: 0, max: 350, step: 10, unit: "deg", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "hoopGap", label: "Ring gap", min: -1500, max: 2000, step: 20, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "hoopScale", label: "Ring scale", min: 0.2, max: 4, step: 0.1, unit: "x", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "hoopSide", label: "Ring side", min: -800, max: 800, step: 10, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + { tab: "banners", id: "hoopFwd", label: "Ring fwd", min: -800, max: 800, step: 10, unit: "cm", box: () => BTUNE, dflt: bcfg, after: pushBanners }, + + { tab: "hoops", id: "goalDistance", label: "Goal distance", min: 2000, max: 12000, step: 100, unit: "cm", box: () => TUNE, dflt: tuning, after: rebuildHoops }, + { tab: "hoops", id: "spacing", label: "Hoop spacing", min: 400, max: 4000, step: 50, unit: "cm", box: () => TUNE, dflt: tuning, after: rebuildHoops }, + { tab: "hoops", id: "axisDeg", label: "Pitch axis", min: 0, max: 180, step: 0.2, unit: "deg", box: () => TUNE, dflt: tuning, after: rebuildHoops }, + { tab: "hoops", id: "yawOffset", label: "Ring yaw +", min: 0, max: 180, step: 90, unit: "deg", box: () => TUNE, dflt: tuning, after: rebuildHoops }, +]; + +function pushBludgerScale() { + if (S) S.bludgers.forEach(placeBludger); +} + +// Only to the seats that may actually use one. Re-pushing on a slider used to arm every player +// with a bat, including the roles that are now refused the swing it is for. +function pushBatToAll() { + for (const seat of seats()) { + pushBat(seat.player, roleAllowed(seat, CONFIG.bludgers.swingRoles) ? CONFIG.bludgers.bat : ""); + } +} + +function rebuildHoops() { + HOOPS = buildHoops(); + pushHoops(); +} + +function mgrValue(sl) { + const box = sl.box(); + const v = box && box[sl.id]; + if (typeof v === "number") return v; + const d = sl.dflt ? sl.dflt() : null; + return (d && typeof d[sl.id] === "number") ? d[sl.id] : 0; +} + +// hmp-ui lower-cases field names, so a slider is found again by its lower-cased id. +function mgrSlider(tab, name) { + return MGR_SLIDERS.find((sl) => sl.tab === tab && sl.id.toLowerCase() === name); +} + +function mgrApply(sl, value) { + const box = sl.box(); + if (!box) return; + box[sl.id] = value; + if (sl.after) sl.after(); +} + +async function showTuning(player) { + const tab = await Ui.context(player, { + title: "Tune the pitch", + description: "Each slider applies as you drag it. Cancel puts the section back as it was.", + options: MGR_TABS.map((t) => ({ id: t.id, title: t.label })), + canClose: true, + }); + if (!tab) return; + const sliders = MGR_SLIDERS.filter((sl) => sl.tab === tab); + const before = sliders.map((sl) => [sl, mgrValue(sl)]); + const fields = sliders.map((sl) => ({ + name: sl.id.toLowerCase(), label: sl.label, type: "range", + min: sl.min, max: sl.max, step: sl.step, unit: sl.unit || undefined, default: mgrValue(sl), + })); + const perHoopBefore = bcfg().perHoop !== false; + if (tab === "banners") fields.unshift({ name: "perhoop", label: "Banners under the rings", type: "checkbox", default: perHoopBefore }); + const label = (MGR_TABS.find((t) => t.id === tab) || {}).label || tab; + const result = await Ui.input(player, { + title: `Tune: ${label}`, + fields, + submitLabel: "Keep", + cancelLabel: "Revert", + }, { + onChange: (name, value) => { + if (name === "perhoop") { BTUNE.perHoop = !!value; pushBanners(); return; } + const sl = mgrSlider(tab, name); + if (sl && typeof value === "number") mgrApply(sl, value); + }, + }); + if (result) return; + // Cancelled, closed or timed out: every slider in the section goes back, touched or not. + for (const [sl, value] of before) mgrApply(sl, value); + if (tab === "banners") { BTUNE.perHoop = perHoopBefore; pushBanners(); } +} + +async function showSnitchControls(player) { + if (!S) return; + const choice = await Ui.context(player, { + title: "Snitch controls", + description: "Freeze holds it still but keeps the catch test running — the way to tell a broken catch from a chase you cannot win.", + options: [ + { id: "freeze", title: S.snitch.frozen ? "Release it" : "Freeze it" }, + { id: "bring", title: "Bring it to me", description: "Steers it to three metres above you." }, + { id: "release", title: "Release now", disabled: !!S.snitch.releasedAt }, + { id: "reset", title: "Reset", description: "Clears a catch in progress and respawns a caught snitch." }, + { id: "blip", title: CONFIG.snitch.blip ? "Hide its marker" : "Show its marker" }, + ], + canClose: true, + }); + if (!choice) return; + if (choice === "freeze") snitchCommand(player, { freeze: !S.snitch.frozen }); + else if (choice === "bring") snitchCommand(player, { bring: true }); + else if (choice === "release") snitchCommand(player, { release: true }); + else if (choice === "reset") snitchCommand(player, { reset: true }); + else if (choice === "blip") snitchCommand(player, { blip: !CONFIG.snitch.blip }); +} + +async function showBludgerControls(player) { + if (!S) return; + const choice = await Ui.context(player, { + title: "Bludger controls", + options: [ + { id: "freeze", title: S.bludgersFrozen ? "Release them" : "Freeze them", description: "Frozen bludgers hold still and hit nobody." }, + { id: "blip", title: CONFIG.bludgers.blip ? "Hide their markers" : "Show their markers" }, + ], + canClose: true, + }); + if (!choice || !S) return; + if (choice === "freeze") S.bludgersFrozen = !S.bludgersFrozen; + else if (choice === "blip") { CONFIG.bludgers.blip = !CONFIG.bludgers.blip; syncMatchBlips(); } +} + +async function showBatChoice(player) { + const bats = CONFIG.bludgers.bats || []; + const result = await Ui.input(player, { + title: "Beater bat", + fields: [ + { name: "bat", label: "Bat", type: "select", default: CONFIG.bludgers.bat || "none", + options: bats.map((b) => ({ value: b.id, label: b.label || b.id })).concat([{ value: "none", label: "None" }]) }, + { name: "socket", label: "Socket", type: "text", default: CONFIG.bludgers.batSocket || "WandSocket" }, + ], + submitLabel: "Apply", + }); + if (!result) return; + CONFIG.bludgers.batSocket = String(result.socket || "WandSocket"); + if (result.bat === "none") pushBat(player, ""); + else { CONFIG.bludgers.bat = String(result.bat); pushBatToAll(); } +} + +let MGR_MODE = "match"; +let MGR_A = "gryffindor"; +let MGR_B = "slytherin"; +let MGR_FFA = false; + +async function showManager(player) { + if (!(await requireManager(player))) return; + const choice = await Ui.context(player, { + title: "Quidditch manager", + description: S ? `${(CONFIG.modes[S.mode] || {}).label || S.mode} is running.` : "Call or start a session.", + options: [ + { id: "call", title: "Call a match", description: "Open a house lineup through Foundations UI." }, + { id: "start", title: "Start immediately", description: "Start a closed-test session with connected players." }, + { id: "lobby", title: "Open current lobby", disabled: !LOBBY }, + { id: "tune", title: "Tune the pitch", description: "Live sliders for the snitch, bludgers, keeper, bat, banners and hoops." }, + { id: "snitch", title: "Snitch controls", description: "Freeze, summon, release or reset the snitch.", disabled: !S }, + { id: "bludgers", title: "Bludger controls", description: "Hold the bludgers still, or toggle their markers.", disabled: !S }, + { id: "bat", title: "Beater bat", description: "Choose the bat and the socket it rides." }, + { id: "pitch", title: "Go to the pitch" }, + { id: "stop", title: "Stop session", disabled: !S, tone: "warning" }, + ], + canClose: true, + }); + if (!choice) return; + if (choice === "pitch") { goToPitch(player); return; } + if (choice === "lobby") { void showLobby(player); return; } + if (choice === "stop") { endSession("manager"); return; } + if (choice === "tune") { await showTuning(player); return; } + if (choice === "snitch") { await showSnitchControls(player); return; } + if (choice === "bludgers") { await showBludgerControls(player); return; } + if (choice === "bat") { await showBatChoice(player); return; } + if (choice === "start") { + const result = await Ui.input(player, { + title: "Start Quidditch", + fields: [{ name: "mode", label: "Mode", type: "select", default: MGR_MODE, + options: Object.keys(CONFIG.modes).map((value) => ({ value, label: CONFIG.modes[value].label || value })) }], + submitLabel: "Start", + }); + if (result) { MGR_MODE = String(result.mode || MGR_MODE); startSession(MGR_MODE, player, true); } + return; + } + const result = await Ui.input(player, { + title: "Call Quidditch match", + fields: [ + { name: "mode", label: "Mode", type: "select", default: MGR_MODE, + options: Object.keys(CONFIG.modes).map((value) => ({ value, label: CONFIG.modes[value].label || value })) }, + { name: "teamA", label: "Home", type: "select", default: MGR_A, + options: HOUSES.map((value) => ({ value, label: cap1(value) })) }, + { name: "teamB", label: "Away", type: "select", default: MGR_B, + options: HOUSES.map((value) => ({ value, label: cap1(value) })) }, + { name: "ffa", label: "Open to all", type: "checkbox", default: MGR_FFA }, + ], + submitLabel: "Call match", + }); + if (!result) return; + MGR_MODE = String(result.mode || MGR_MODE); + MGR_A = String(result.teamA || MGR_A); + MGR_B = String(result.teamB || MGR_B); + MGR_FFA = !!result.ffa; + openLobby(MGR_MODE, player, MGR_A, MGR_B, MGR_FFA); +} + +// ---------------------------------------------------------------- rp-ui + commands + +const pendingTeams = new Map(); +// Same idea for the position. Roles are dealt round-robin without one, so a solo tester is a +// chaser by arithmetic and can never be the Seeker whose rule they are trying to check. +const pendingRoles = new Map(); + +const PASS_KEY = String((CONFIG.ball && CONFIG.ball.passKey) || "q").toLowerCase(); +const SHOOT_KEY = String((CONFIG.ball && CONFIG.ball.shootKey) || "r").toLowerCase(); +const SWING_KEY = String((CONFIG.bludgers && CONFIG.bludgers.swingKey) || "v").toLowerCase(); +const BLOCK_KEY = String((CONFIG.keeper && CONFIG.keeper.blockKey) || "b").toLowerCase(); +const TACKLE_KEY = String((CONFIG.keeper && CONFIG.keeper.tackleKey) || "t").toLowerCase(); + +if (Events.listenerCount("rp:ui:register") > 0) { + Events.emit("rp:ui:register", { + id: "quidditch", + resource: "quidditch", + actions: [ + // `prompt: true` is what makes rp-ui float a world plate over the ball reading "[E] Take + // Quaffle" — it is opt-in, and without it the hotkey is bound but undiscoverable. The + // plate needs an anchor too, which the context offer below supplies. + { id: "quid.pickup", label: "Take Quaffle", hint: "Grab the loose ball", group: "Quidditch", order: 10, hotkey: "e", prompt: true }, + { id: "quid.pass", label: "Pass", hint: "Throw to the nearest team-mate", group: "Quidditch", order: 20, hotkey: PASS_KEY }, + { id: "quid.shoot", label: "Shoot", hint: "Throw at the hoops", group: "Quidditch", order: 30, tone: "gold", hotkey: SHOOT_KEY }, + { id: "quid.tackle", label: "Tackle", hint: "Put hands on the carrier", group: "Quidditch", order: 34, hotkey: TACKLE_KEY }, + { id: "quid.block", label: "Block", hint: "Clear the ball away", group: "Quidditch", order: 35, hotkey: BLOCK_KEY }, + { id: "quid.swing", label: "Swing", hint: "Bat the bludger away", group: "Quidditch", order: 40, hotkey: SWING_KEY }, + { id: "quid.join", label: "Join Match", hint: "Pick a position, or take an open seat", group: "Quidditch", order: 5 }, + { id: "quid.leave", label: "Leave Pitch", group: "Quidditch", order: 90 }, + ], + }); +} + +// Every context build records why it did or did not offer the pickup, so `/quidditch why` can +// report the decision rp-ui actually re-validated against. A press that rp-ui rejects as "no longer +// legal" means this handler declined on THAT build — the manual `/rpui context` run is a different +// build, and it has been passing while the press fails, so the two have to be compared directly. +const CTX_LOG = []; + +Events.on("rp:ui:context", (q) => { + if (!q || !q.player) { CTX_LOG.unshift({ at: Date.now(), why: "no q.player" }); CTX_LOG.length = Math.min(CTX_LOG.length, 8); return; } + if (!S) { + // No session, but a lobby is still something to join. Without this the wheel went empty the + // moment a match was called, which is exactly when someone is looking for a way in. + if (LOBBY && lobbyEligible(q.player)) q.actions.push({ id: "quid.join" }); + CTX_LOG.unshift({ at: Date.now(), why: LOBBY ? "lobby open — offered join" : "no session" }); + CTX_LOG.length = Math.min(CTX_LOG.length, 8); + return; + } + const seat = seatOf(q.player); + if (!seat) { + // The one person who needs an offer used to be the one person refused context entirely. + q.actions.push({ id: "quid.join" }); + CTX_LOG.unshift({ at: Date.now(), why: `no seat for #${q.player.id} — offered join` }); + CTX_LOG.length = Math.min(CTX_LOG.length, 8); + return; + } + const d = seat.lastPos ? Math.round(dist(seat.lastPos, S.ball.pos)) : -1; + const near = S.ball.loose && seat.lastPos && d < (CONFIG.ball.pickupRadius || 700) + && roleAllowed(seat, CONFIG.ball.carryRoles); + CTX_LOG.unshift({ at: Date.now(), why: near ? "OFFERED" : `d=${d} loose=${S.ball.loose} pos=${!!seat.lastPos}`, id: q.player.id }); + CTX_LOG.length = Math.min(CTX_LOG.length, 8); + if (near) { + q.actions.push({ id: "quid.pickup", x: S.ball.pos.x, y: S.ball.pos.y, z: S.ball.pos.z }); + } + if (S.ball.carrier === q.player.id) { q.actions.push({ id: "quid.pass" }); q.actions.push({ id: "quid.shoot" }); } + // Beaters only, by `bludgers.swingRoles`. Role used to be a label here rather than a permission. + if (nearestBludger(seat) && roleAllowed(seat, CONFIG.bludgers.swingRoles)) { + q.actions.push({ id: "quid.swing" }); + } + // Tackle: a Keeper, on a carrier, inside the scoring area. The offer shows a player exactly + // when it is legal rather than making them guess -- but tackle() re-checks all of it, because + // an offer is advisory and a client can name an action it was never shown. + { + const carrier = ballHolder(); + const k = CONFIG.keeper || {}; + if (roleAllowed(seat, k.tackleRoles) + && carrier && carrier.player.id !== q.player.id && carrier.lastPos && seat.lastPos + && dist(seat.lastPos, carrier.lastPos) <= (k.tackleRadius || 700) + && inScoringArea(carrier.lastPos, endKeptBy(seat))) { + q.actions.push({ id: "quid.tackle" }); + } + } + // The Keeper's, by `keeper.blockRoles`. This used to be offered to everyone, which meant a + // Chaser in range saw Take Quaffle and Block at once -- two keys for one situation, and the + // second one throws away the ball the first one would have won them. + if (S.ball.loose && !S.ball.carrier && seat.lastPos + && roleAllowed(seat, (CONFIG.keeper || {}).blockRoles) + && dist(seat.lastPos, S.ball.pos) <= ((CONFIG.keeper && CONFIG.keeper.blockRadius) || 1000)) { + q.actions.push({ id: "quid.block" }); + } + q.actions.push({ id: "quid.leave" }); +}); + +Events.on("rp:ui:action", (player, actionId) => { + // Before the seat check, because having no seat is the precondition for this one. + if (actionId === "quid.join") { joinSession(player); return; } + const seat = seatOf(player); + if (!seat || !S) return; + if (actionId === "quid.pickup") { + // Re-check the range here too. The wheel only OFFERS this in range, but the offer is + // advisory — a client picks an action by id, and nothing stops it naming one it was never + // shown. The pickup event path checks the same thing. + if (!S.ball.loose || !seat.lastPos) return; + if (dist(seat.lastPos, S.ball.pos) > (CONFIG.ball.pickupRadius || 700)) return; + grantPossession(seat); + return; + } + if (actionId === "quid.tackle") { tackle(seat); return; } + if (actionId === "quid.block") { + try { player.emit("quidditch:aimRequest", JSON.stringify({ kind: "block" })); } catch (_) {} + return; + } + if (actionId === "quid.swing") { + // Aim comes from the client and the launch is re-derived here — same contract as a throw. + try { player.emit("quidditch:aimRequest", JSON.stringify({ kind: "swing" })); } catch (_) {} + return; + } + if (actionId === "quid.leave") { S.players.delete(player.id); try { player.emit("quidditch:teardown", "{}"); } catch (_) {} return; } + if (actionId === "quid.pass" || actionId === "quid.shoot") { + try { player.emit("quidditch:aimRequest", JSON.stringify({ kind: actionId === "quid.pass" ? "pass" : "shoot" })); } catch (_) {} + } +}); + +Events.on("chatCommand", async (player, message, command, args) => { + if (command !== "quidditch") return; + const sub = (args && args[0]) || ""; + if (MANAGE_SUBS.has(sub) && !(await requireManager(player))) return; + // /quidditch call [mode] — a fixture, told only to those two houses + // /quidditch call ffa [mode] — open to everyone, either side + if (sub === "call") { + const a = String(args[1] || "").toLowerCase(); + if (a === "ffa" || a === "open") { + openLobby(args[2] || "match", player, null, null, true); + return; + } + const b = String(args[2] || "").toLowerCase(); + if (a && !HOUSES.includes(a)) { + player.sendChat(`Houses: ${HOUSES.join(", ")} — or "ffa". /quidditch call gryffindor slytherin`); + return; + } + if (b && !HOUSES.includes(b)) { + player.sendChat(`Houses: ${HOUSES.join(", ")}`); + return; + } + openLobby(args[3] || "match", player, a || null, b || null, false); + return; + } + if (sub === "cancel") { + if (!LOBBY) { player.sendChat("No match has been called."); return; } + if (player.id !== LOBBY.refereeId) { player.sendChat("Only the referee can call it off."); return; } + closeLobby("The match was called off."); + return; + } + if (sub === "lobby") { + if (!LOBBY) { player.sendChat("No match has been called. /quidditch call [mode]"); return; } + showLobby(player); + return; + } + if (sub === "start") { + // A called lobby wins: starting by hand should still seat the sides people picked, not + // discard them and deal roles round-robin. + if (LOBBY) { void startFromLobby(true).catch((error) => logger.warn(`Lobby start failed: ${error}`)); return; } + const solo = String(args[2] || "").toLowerCase() === "solo" || String(args[2] || "").toLowerCase() === "force"; + startSession(args[1] || "snitchHunt", player, solo); + return; + } + if (sub === "stop") { endSession("stopped"); return; } + if (sub === "team") { + const house = String(args[1] || "").toLowerCase(); + if (!HOUSES.includes(house)) { player.sendChat(`Houses: ${HOUSES.join(", ")}`); return; } + pendingTeams.set(player.id, house); + player.sendChat(`You will play for ${house}.`); + return; + } + if (sub === "join") { joinSession(player); return; } + if (sub === "pitch") { goToPitch(player); return; } + if (sub === "role") { + const role = String(args[1] || "").toLowerCase(); + if (!CONFIG.roles.includes(role)) { player.sendChat(`Roles: ${CONFIG.roles.join(", ")}`); return; } + pendingRoles.set(player.id, role); + const seat = seatOf(player); + if (!seat) { player.sendChat(`You will play as ${role}.`); return; } + // Live, so a rule can be checked without restarting the session to check it. + seat.role = role; + pushBat(player, roleAllowed(seat, CONFIG.bludgers.swingRoles) ? CONFIG.bludgers.bat : ""); + pushHud(seat); + if (Events.listenerCount("rp:ui:context:poke") > 0) { + try { Events.emit("rp:ui:context:poke", player); } catch (_) {} + } + player.sendChat(`You are now ${role}.`); + return; + } + if (sub === "open" || sub === "manager") { + void showManager(player).catch((error) => logger.warn(`Manager failed for ${player.id}: ${error}`)); + return; + } + // Probe surface: drop a lone quaffle in front of you with no session running, so Accio / + // Wingardium / Depulso can be tested against a SmallPhysicsProp body on its own. + if (sub === "ball") { + let p = null; + try { p = player.position; } catch (_) {} + if (!p) { player.sendChat("No position yet."); return; } + const ball = spawnQuaffle(TEST_QUAFFLE_KEY, { x: p.x, y: p.y, z: p.z + 250 }); + if (ball) ball.setVirtualWorld(player.virtualWorld); + player.sendChat(ball ? "Quaffle dropped in front of you. /quidditch ballclear to remove it." : "Quaffle spawn failed — check ball.mesh."); + return; + } + if (sub === "ballclear") { + player.sendChat(WorldObject.destroy(TEST_QUAFFLE_KEY) ? "Quaffle removed." : "No test quaffle to remove."); + return; + } + // Ground truth for "why can I not pick this up". Answers the three things that decide it: where + // the server thinks the ball is, how far that is from you against the pickup radius, and whether + // it is resting on the deck or still falling. + // Press E, get the rejection, then run this. The top line is the build rp-ui re-validated + // against, so it says exactly which term of the predicate was false at that instant. + // Preview the results panel. With a session it shows the real one; without, a sample — so the + // layout can be checked without playing a match to the end. + if (sub === "results") { + // A preview outside a session has no teardown to clean it up, so it needs its own way out. + if (String(args[1] || "").toLowerCase() === "off") { + try { player.emit("quidditch:resultsClose", "{}"); } catch (_) {} + player.sendChat("Results panel closed."); + return; + } + const d = S ? resultsPayload("snitch") : { + reason: "snitch", mode: "Full Match", winner: "gryffindor", durationSec: 247, + score: [{ house: "gryffindor", points: 190 }, { house: "slytherin", points: 60 }], + players: [ + { name: player.nickname || "You", team: "gryffindor", role: "seeker", goals: 1, snitch: 1, points: 160 }, + { name: "Weasley", team: "gryffindor", role: "chaser", goals: 3, snitch: 0, points: 30 }, + { name: "Malfoy", team: "slytherin", role: "chaser", goals: 6, snitch: 0, points: 60 }, + { name: "Crabbe", team: "slytherin", role: "beater", goals: 0, snitch: 0, points: 0 }, + ], + }; + d.afterMs = 200; // no banner to wait behind when previewing + try { player.emit("quidditch:results", JSON.stringify(d)); } catch (_) {} + player.sendChat(S ? "Results panel (live session)." : "Results panel (sample data)."); + return; + } + // Preview the banners without having to score, which matters because scoring is the one path + // still unproven — and a demo recording should not be the first time these are seen. + // Bisect: is the mesh not rendering, or is it just not where anyone looks? This drops one in + // front of you at a size you cannot miss. + if (sub === "bannertest") { + let p = null; + try { p = player.position; } catch (_) {} + if (!p) { player.sendChat("No position yet."); return; } + const house = String(args[1] || "gryffindor").toLowerCase(); + const mesh = (P.goalBanners || {})[house]; + if (!mesh) { player.sendChat(`Houses: ${Object.keys(P.goalBanners || {}).join(", ")}`); return; } + const scale = Number(args[2]) || 3; + // Four of them, one per quarter turn. A banner is a flat plane and its material may well be + // single-sided, in which case three of these are invisible and the fourth is the answer — + // which is a thing to see rather than reason about. + const yaw = args[3] !== undefined ? Number(args[3]) : null; + const spread = yaw === null ? [0, 90, 180, 270] : [yaw]; + const banners = spread.map((y, i) => ({ + mesh, x: p.x + 600 + i * 500, y: p.y, z: p.z, yaw: y, scale, + })); + placeBanners(banners); + player.sendChat(yaw === null + ? `Four ${house} banners in front of you at yaw 0 / 90 / 180 / 270 — which face you?` + : `Banner ${house} at yaw ${yaw}, scale ${scale}.`); + return; + } + if (sub === "banner") { + const which = String(args[1] || "goal").toLowerCase(); + const house = String(args[2] || "gryffindor").toLowerCase(); + const payload = which === "snitch" + ? { size: "snitch", house, text: "The Snitch is caught", subLead: house, subTail: `· +${CONFIG.snitch.points}`, holdMs: 4200 } + : { size: "goal", house, lead: house, tail: "score", sub: `+${CONFIG.scoring.goal}` }; + try { player.emit("quidditch:banner", JSON.stringify(payload)); } catch (_) {} + player.sendChat(`Banner: ${which} (${house}). /quidditch banner snitch slytherin`); + return; + } + // Escape hatch. A stuck rider cannot type their way out of a broom flag stack any other way. + if (sub === "unstick") { + try { player.emit("quidditch:unstick", "{}"); } catch (_) {} + clearStunCard(player.id); + if (S) S.stunned.delete(player.id); + player.sendChat("Input restored."); + return; + } + if (sub === "bat") { + const arg = String(args[1] || "").toLowerCase(); + if (arg === "off") { pushBat(player, ""); player.sendChat("Bat put away."); return; } + const list = (CONFIG.bludgers.bats || []).map((b) => b.id); + const match = list.find((id) => id.toLowerCase() === arg); + if (!arg) { player.sendChat(`/quidditch bat <${list.join("|")}|off> — current ${CONFIG.bludgers.bat}`); return; } + if (!match) { player.sendChat(`Unknown bat. Try: ${list.join(", ")}`); return; } + CONFIG.bludgers.bat = arg; + pushBat(player, arg); + player.sendChat(`Bat: ${arg}.`); + return; + } + if (sub === "bludgers") { + const n = Number(args[1]); + if (isNaN(n)) { player.sendChat(`/quidditch bludgers — currently ${bludgerCount ?? CONFIG.bludgers.count}`); return; } + bludgerCount = Math.max(0, Math.min(MAX_BLUDGERS, Math.round(n))); + player.sendChat(`Bludgers set to ${bludgerCount}. Takes effect on the next start.`); + return; + } + if (sub === "why") { + if (!CTX_LOG.length) { player.sendChat("No context builds recorded — is rp-ui running?"); return; } + const now = Date.now(); + for (const e of CTX_LOG) player.sendChat(`${String(now - e.at).padStart(5)} ms ago — ${e.why}${e.id !== undefined ? ` (#${e.id})` : ""}`); + return; + } + if (sub === "ballwhere") { + if (!S) { player.sendChat("No session — /quidditch start practice solo"); return; } + const b = S.ball.pos; + const seat = seatOf(player); + const d = seat && seat.lastPos ? Math.round(dist(seat.lastPos, b)) : -1; + const aboveDeck = Math.round(b.z - DECK_Z); + const moved = S.ball.prev ? Math.round(dist(S.ball.prev, b)) : -1; + player.sendChat(`Ball (${Math.round(b.x)}, ${Math.round(b.y)}, ${Math.round(b.z)}) — ${aboveDeck} cm above deck`); + player.sendChat(`You are ${d} cm away (pickup at ${CONFIG.ball.pickupRadius}), moved ${moved} cm last tick`); + player.sendChat(`loose=${S.ball.loose} carrier=${S.ball.carrier || "none"} simulator=${S.ball.obj ? S.ball.obj.ownerId : "none"} you=${player.id}`); + return; + } + // Hoop placement is the one thing that genuinely needs eyes in-game, so it is tunable live. + // Trail scale is baked in at spawn, so testing a value means respawning the snitch. Fast loop, + // because "is this VFX rendering at all" needs several attempts to answer honestly. + if (sub === "trail") { + const scale = Number(args[1]); + if (isNaN(scale)) { player.sendChat(`/quidditch trail — current ${CONFIG.snitch.trailScale}`); return; } + CONFIG.snitch.trailScale = scale; + if (!S || !S.snitch.obj) { player.sendChat("No released snitch — start a session first."); return; } + spawnSnitch(); + broadcast("quidditch:resnitch", { id: S.snitch.obj ? S.snitch.obj.id : 0 }); + player.sendChat(`Snitch respawned with trail scale ${scale}.`); + return; + } + if (sub === "hoopheight") { + const outer = Number(args[1]), mid = Number(args[2]); + if (isNaN(outer)) { player.sendChat("/quidditch hoopheight [centre] — cm above the deck"); return; } + HOOPS = buildHoops({ heights: [outer, isNaN(mid) ? outer : mid, outer] }); + pushHoops(true); + const g = tuning(); + player.sendChat(`Hoops: dist ${g.goalDistance}, spacing ${g.spacing}, heights ${g.heights.join("/")} above deck.`); + return; + } + if (sub === "hoops") { + const over = {}; + if (args[1] !== undefined && !isNaN(Number(args[1]))) over.goalDistance = Number(args[1]); + if (args[2] !== undefined && !isNaN(Number(args[2]))) over.axisDeg = Number(args[2]); + if (args[3] !== undefined && !isNaN(Number(args[3]))) over.spacing = Number(args[3]); + if (args[4] !== undefined && !isNaN(Number(args[4]))) over.yawOffset = Number(args[4]); + HOOPS = buildHoops(over); + pushHoops(true); + const g = tuning(); + player.sendChat(`Hoops: dist ${g.goalDistance}, axis ${g.axisDeg}, spacing ${g.spacing}, yaw+${g.yawOffset || 0}, heights ${g.heights.join("/")}.`); + return; + } + // The mounted-cast unlock is applied on session setup, so outside a session casting on a broom + // is still stock (Revelio only). This toggles it on its own for probing. + if (sub === "cast") { + const on = String(args[1] || "on").toLowerCase() !== "off"; + try { player.emit("quidditch:testCast", JSON.stringify({ on })); } catch (_) {} + player.sendChat(`Mounted casting ${on ? "unlocked" : "restored to stock"} on your client.`); + return; + } + if (S) { player.sendChat(`Quidditch: ${CONFIG.modes[S.mode].label}, ${seats().length} on the pitch.`); return; } + player.sendChat("Quidditch: idle. /quidditch start [solo] | /quidditch ball | /quidditch cast on"); +}); + +const unregisterPitch = Interact.register({ + id: "quidditch.pitch", + resource: RESOURCE, + label: "Quidditch", + description: "Join the lineup or manage a match.", + position: { x: CENTRE.x, y: CENTRE.y, z: DECK_Z + (P.spawnHeight || 0) }, + radius: 450, + promptDistance: 350, + requirements: { character: true }, + handler({ player }) { + if (LOBBY && lobbyEligible(player)) void showLobby(player); + else void showManager(player).catch((error) => logger.warn(`Manager failed for ${player.id}: ${error}`)); + }, +}); + +function status() { + return { + state: S ? (S.ending ? "ending" : "running") : (LOBBY ? "forming" : "idle"), + mode: S ? S.mode : (LOBBY ? LOBBY.mode : null), + players: S ? S.players.size : (LOBBY ? LOBBY.picks.size : 0), + activitySessionId: S ? S.activitySessionId : (LOBBY ? LOBBY.activitySessionId : null), + dependencies: { + activities: Activities.status().state, + houses: Houses.status().state, + world: FoundationWorld.status().state, + }, + }; +} + +Exports.register("status", status); +Exports.register("call", (referee, mode = "match", teamA, teamB, ffa = false) => openLobby(mode, referee, teamA, teamB, ffa)); +Exports.register("start", (starter, mode = "match", solo = false) => startSession(mode, starter, solo)); +Exports.register("stop", (reason = "external") => endSession(reason)); + +Events.on("playerConnect", (player) => { + void Houses.membership.sync(player).catch((error) => logger.warn(`House sync failed for ${player.id}: ${error}`)); +}); + +// Eligibility is the character's house, and a connecting client is not yet listening, so a call in +// progress reaches a late joiner once their character has loaded. +Events.on("hmp:character:loaded", (payload) => { + const player = payload && payload.session && payload.session.player; + if (player && LOBBY) pushLobbyCard(player); +}); + +// The card's key: the picker opens only when somebody asks for it. +Events.onClient("quidditch:lobbyOpen", (player) => { + if (LOBBY && lobbyEligible(player)) void showLobby(player); +}); + +Events.on("playerDisconnect", (player) => { + if (LOBBY) { + LOBBY.picks.delete(player.id); + // The referee leaving would otherwise strand a lobby nobody can start or cancel. + if (player.id === LOBBY.refereeId) closeLobby("The referee left — the match was called off."); + else pushLobbyCards(); + } + if (!S) return; + if (S.ball.carrier === player.id) dropBall("disconnect"); + // Hold the seat before letting go of it, so a crash costs a minute rather than the match. + const seat = S.players.get(player.id); + if (seat && seat.team) { + S.vacated.set(nameKey(player), { + team: seat.team, role: seat.role, stats: seat.stats, + until: Date.now() + ((CONFIG.lobby && CONFIG.lobby.rejoinSeconds) || 300) * 1000, + }); + } + S.players.delete(player.id); + if (!S.players.size) endSession("empty"); +}); + +// World objects belong to the server, not the script, so a hot reload would otherwise strand them. +Events.on("resourceStop", (name) => { + if (name && name !== RESOURCE) return; + if (LOBBY) closeLobby("Quidditch resource stopped."); + if (S) endSession("resource stopped"); + placeBanners([]); + clearBludgers(); + clearQuaffle(); + clearHoopObjects(); + clearSnitch(); + try { WorldObject.destroy(TEST_QUAFFLE_KEY); } catch (_) {} + unregisterPitch(); + void unregisterActivity(); + FoundationAudio.sounds.release(AUDIO_OWNER); + clearMatchBlips(); +}); + +logger.info("Server ready"); diff --git a/gamemodes/resources/hmp-quidditch/test/bundle.test.js b/gamemodes/resources/hmp-quidditch/test/bundle.test.js new file mode 100644 index 0000000..a0c5c85 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/test/bundle.test.js @@ -0,0 +1,57 @@ +const assert = require("node:assert"); +const path = require("node:path"); + +const exportsMap = new Map(); +const serverHandlers = new Map(); +const clientHandlers = new Map(); +const activity = { + definitions: { register: () => async () => 0 }, + sessions: { + create: async () => ({ id: "activity-1", participants: [] }), get: () => null, + join: async () => ({}), start: async () => ({}), complete: async () => ({}), cancel: async () => ({}), + }, + status: () => ({ state: "ready" }), +}; +const logger = { info() {}, warn() {}, error() {}, debug() {} }; +const services = { + "hmp-lib": { logger: { create: () => logger }, input: { shortcuts: { register: () => () => true }, cleanup() {} } }, + "hmp-core": { characters: { active: () => null }, metadata: { getCharacter: async () => ({}), setCharacter: async () => ({}) } }, + "hmp-activities": activity, + "hmp-ui": { notify() {}, close() {}, context: async () => null, input: async () => null }, + "hmp-houses": { membership: { sync: async () => null }, status: () => ({ state: "ready" }) }, + "hmp-inventory": { native: { list: () => [], give: async () => 1 } }, + "hmp-interact": { register: () => () => true }, + "hmp-audio": { sounds: { playForAll: () => null, playAt: () => null, playForPlayer: () => null, stop: () => true, release: () => 0 } }, + "hmp-blips": { markers: { clear: () => 0, upsert: async (value) => value, remove: () => true } }, + "hmp-world": { policy: { restore: () => true }, status: () => ({ state: "ready" }) }, +}; + +global.Imports = { get: (name) => services[name] }; +global.Exports = { register: (name, value) => exportsMap.set(name, value) }; +global.PlayerManager = { getAll: () => [], getById: () => null }; +global.StateTable = { set() {} }; +global.Events = { + on: (name, handler) => serverHandlers.set(name, handler), + onClient: (name, handler) => serverHandlers.set(`client:${name}`, handler), + listenerCount: () => 0, + emit() {}, emitServer() {}, +}; +const realSetInterval = global.setInterval; +global.setInterval = () => ({ unref() {} }); +require(path.resolve(__dirname, "..", "dist", "server.js")); +assert.deepStrictEqual([...exportsMap.keys()], ["status", "call", "start", "stop"]); +assert.ok(serverHandlers.has("client:quidditch:pickup")); +assert.ok(serverHandlers.has("client:quidditch:tackle")); +assert.strictEqual(exportsMap.get("status")().state, "idle"); + +global.Events = { on: (name, handler) => clientHandlers.set(name, handler), emitServer() {} }; +global.Game = { notify() {} }; +global.World = { worldObjectState: () => null }; +global.Broom = {}; +global.LocalPlayer = {}; +global.Web = {}; +require(path.resolve(__dirname, "..", "dist", "client.js")); +assert.ok(clientHandlers.has("quidditch:setup")); +assert.ok(clientHandlers.has("resourceStop")); +global.setInterval = realSetInterval; +console.log("hmp-quidditch bundle contract passed"); diff --git a/gamemodes/resources/hmp-quidditch/test/foundations.test.js b/gamemodes/resources/hmp-quidditch/test/foundations.test.js new file mode 100644 index 0000000..ea23989 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/test/foundations.test.js @@ -0,0 +1,31 @@ +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const server = fs.readFileSync(path.join(root, "server", "main.js"), "utf8"); +const client = fs.readFileSync(path.join(root, "client", "main.js"), "utf8"); +const dependencies = new Set(manifest.mafiahub.resourceDependencies.map((entry) => entry.name)); + +assert.strictEqual(manifest.name, "hmp-quidditch"); +for (const name of ["hmp-lib", "hmp-core", "hmp-activities", "hmp-ui", "hmp-houses", "hmp-inventory", "hmp-interact", "hmp-audio", "hmp-blips", "hmp-world"]) { + assert.ok(dependencies.has(name), `missing ${name} dependency`); + assert.ok(server.includes(`Imports.get("${name}")`) || client.includes(`Imports.get("${name}")`), `${name} is declared but not imported`); +} +assert.ok(server.includes("Core.metadata.setCharacter"), "career totals must be character-scoped"); +assert.ok(server.includes("Activities.sessions"), "match lifecycle must use hmp-activities"); +assert.ok(server.includes("FoundationAudio.sounds"), "audio must use hmp-audio"); +assert.ok(server.includes("FoundationBlips.markers"), "markers must use hmp-blips"); +assert.ok(client.includes("Hmp.input.shortcuts.register"), "shortcuts must use hmp-lib arbitration"); +assert.ok(client.includes("FoundationWorld.policy.restore"), "world policy must restore through hmp-world"); +assert.ok(!server.includes("FoundationWorld.policy.set"), "a match must not change the server-wide world policy"); +assert.ok(server.includes("Core.groups.has"), "match control must check hmp-core groups"); +assert.ok(!manifest.mafiahub.resourceDependencies.some((entry) => ["rp-ui", "interactables"].includes(entry.name))); +// The pitch is server WorldObjects: no client builtin and no client reporting where things are. +for (const [side, source] of [["server", server], ["client", client]]) { + assert.doesNotMatch(source, /\bQuidditch\.\w+\(/, `${side} still calls the removed client Quidditch builtin`); + assert.doesNotMatch(source, /quidditch:(ballAt|snitchAt)/, `${side} still trusts a client position report`); +} +assert.ok(server.includes("WorldObject.create"), "the pitch must be built from server WorldObjects"); +console.log("hmp-quidditch Foundations integration contract passed"); diff --git a/gamemodes/resources/hmp-quidditch/test/server.test.js b/gamemodes/resources/hmp-quidditch/test/server.test.js new file mode 100644 index 0000000..cc28f70 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/test/server.test.js @@ -0,0 +1,191 @@ +// Runs the server script against fakes: what a session puts in the world, who may manage it, and the +// live tuning dialog. The game itself is not involved; that is the in-game QA suite's job. +const assert = require("node:assert"); +const path = require("node:path"); + +const CONFIG = require("../quidditch.config.js"); +const RING = "/Game/Gameplay/BroomRaces/Blueprints/BP_RaceRing.BP_RaceRing_C"; +const SNITCH = "/Game/Levels/WorldEvents/Blueprints/LooseSnitch/BP_Loose_Snitch.BP_Loose_Snitch_C"; + +const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((resolve) => setImmediate(resolve)); }; + +// ---------------------------------------------------------------- world objects +const objects = new Map(); +let nextId = 100; +global.WorldObject = { + create(key, model, opts) { + const state = new Map(); + const object = { + id: nextId++, key, model, opts, ownerId: 0, + position: { x: opts.x, y: opts.y, z: opts.z }, + state: { set: (k, v) => { state.set(k, v); return true; }, get: (k) => state.get(k) }, + stateMap: state, + attachTo: () => true, detach: () => true, setVelocity: () => true, setVirtualWorld() {}, + }; + objects.set(key, object); + return object; + }, + destroy: (key) => objects.delete(key), +}; +global.Vector3 = class { constructor(x, y, z) { Object.assign(this, { x, y, z }); } }; +global.World = { state: { set() {}, remove() {} } }; + +// ---------------------------------------------------------------- players +const makePlayer = (id, nickname) => ({ + id, nickname, house: "", chats: [], events: [], position: { x: 0, y: 0, z: 0 }, + emit(name, payload) { this.events.push({ name, payload }); }, sendChat(text) { this.chats.push(text); }, teleport: () => 1, +}); +const emitted = (player, name) => player.events.filter((e) => e.name === name).map((e) => JSON.parse(e.payload)); +const admin = makePlayer(1, "Admin"); +const guest = makePlayer(2, "Guest"); +let online = [admin, guest]; +global.PlayerManager = { getAll: () => online, getById: (id) => online.find((p) => p.id === id) || null }; + +// ---------------------------------------------------------------- foundations +const logger = { info() {}, warn() {}, error() {}, debug() {} }; +const markers = new Map(); +const uiAnswers = []; +let inputCall = null; +let contextCalls = 0; +const services = { + "hmp-lib": { logger: { create: () => logger }, input: { shortcuts: { register: () => () => true }, cleanup() {} } }, + "hmp-core": { + characters: { active: () => null }, + metadata: { getCharacter: async () => ({}), setCharacter: async () => ({}) }, + groups: { has: async (player, key, grade) => key === "admin" && grade === 1 && (player.id ?? player) === admin.id }, + }, + "hmp-activities": { + definitions: { register: () => async () => 0 }, + sessions: { + create: async () => ({ id: "activity-1", participants: [] }), get: () => null, + join: async () => ({}), start: async () => ({}), complete: async () => ({}), cancel: async () => ({}), + }, + status: () => ({ state: "ready" }), + }, + "hmp-ui": { + notify() {}, close() {}, + context: async () => { contextCalls++; return uiAnswers.shift() ?? null; }, + input: (player, dialog, options) => new Promise((resolve) => { inputCall = { player, dialog, options, resolve }; }), + }, + "hmp-houses": { membership: { sync: async () => null }, status: () => ({ state: "ready" }) }, + "hmp-inventory": { native: { list: () => [], give: async () => 1 } }, + "hmp-interact": { register: () => () => true }, + "hmp-audio": { sounds: { playForAll: () => null, playAt: () => null, playForPlayer: () => null, stop: () => true, release: () => 0 } }, + "hmp-blips": { markers: { clear: () => 0, upsert: async (marker) => { markers.set(marker.id, marker); return marker; }, remove: (id) => markers.delete(id) } }, + // A match never changes the server-wide world policy; any call here throws. + "hmp-world": { status: () => ({ state: "ready" }) }, +}; + +const exportsMap = new Map(); +const handlers = new Map(); +global.Imports = { get: (name) => services[name] }; +global.Exports = { register: (name, value) => exportsMap.set(name, value) }; +global.Events = { + on: (name, handler) => handlers.set(name, handler), + onClient: (name, handler) => handlers.set(`client:${name}`, handler), + listenerCount: () => 0, + emit() {}, +}; +const intervals = []; +const realSetInterval = global.setInterval; +const realSetTimeout = global.setTimeout; +global.setInterval = (fn, ms) => { intervals.push({ fn, ms }); return { unref() {} }; }; +global.setTimeout = (fn, ms) => (ms > 1000 ? { unref() {} } : realSetTimeout(fn, ms)); + +require(path.resolve(__dirname, "..", "server", "main.js")); +const tickMs = Math.max(20, Math.floor(1000 / (CONFIG.scoring.tickHz || 20))); +const tick = intervals.find((entry) => entry.ms === tickMs).fn; +const start = exportsMap.get("start"); +const stop = exportsMap.get("stop"); +const chat = (player, ...args) => handlers.get("chatCommand")(player, `/quidditch ${args.join(" ")}`, "quidditch", args); + +(async () => { + // Practice: the quaffle is a physics object, the hoops are race-ring Blueprints, nothing else. + start(admin, "practice", true); + await flush(); + const quaffle = objects.get("quidditch:quaffle"); + assert.ok(quaffle, "practice spawned no quaffle"); + assert.strictEqual(quaffle.model, CONFIG.ball.mesh); + assert.strictEqual(quaffle.opts.physics, true); + const rings = [...objects.values()].filter((o) => o.model === RING); + assert.strictEqual(rings.length, CONFIG.pitch.hoopGeometry.heights.length * 2); + assert.ok(![...objects.values()].some((o) => o.model === SNITCH), "practice released a snitch"); + + stop("test"); + await flush(); + assert.strictEqual(objects.size, 0, `stopping left ${[...objects.keys()].join(", ")}`); + + // Snitch hunt: one simulated snitch Blueprint and the configured bludgers. + start(admin, "snitchHunt", true); + await flush(); + const snitch = [...objects.values()].find((o) => o.model === SNITCH); + assert.ok(snitch, "the snitch hunt released no snitch"); + assert.strictEqual(snitch.opts.simulated, true); + assert.strictEqual([...objects.values()].filter((o) => o.model === CONFIG.bludgers.mesh).length, CONFIG.bludgers.count); + + // The marker follows the snitch its simulator flies, not the server's steering point. + handlers.get("client:quidditch:snitchCmd")(admin, { blip: true }); + await flush(); + snitch.position = { x: 1234, y: 5678, z: 910 }; + for (let i = 0; i < 10; i++) tick(); + await flush(); + assert.deepStrictEqual(markers.get("snitch")?.position, { x: 1234, y: 5678, z: 910 }); + assert.strictEqual(typeof snitch.stateMap.get("snitch.x"), "number", "the snitch was never steered"); + + // Match control: staff only while staff is online, anyone once nobody qualifies. + handlers.get("client:quidditch:manageStart")(guest, { stop: true }); + await flush(); + assert.ok([...objects.values()].some((o) => o.model === SNITCH), "a guest stopped the match while an admin was online"); + assert.match(guest.chats.at(-1), /admin group/); + online = [guest]; + handlers.get("client:quidditch:manageStart")(guest, { stop: true }); + await flush(); + assert.strictEqual(objects.size, 0, "with no admin online the guest could not stop the match"); + online = [admin, guest]; + + // Tuning: a slider applies live and Revert puts it back. + const before = CONFIG.snitch.catchRadius; + uiAnswers.push("tune", "snitch"); + await chat(admin, "manager"); + await flush(); + assert.ok(inputCall, "the tuning dialog never opened"); + const field = inputCall.dialog.fields.find((f) => f.name === "catchradius"); + assert.ok(field && field.type === "range", "the snitch tab has no catch-radius slider"); + assert.strictEqual(field.default, before); + inputCall.options.onChange("catchradius", 1200); + assert.strictEqual(CONFIG.snitch.catchRadius, 1200, "the slider did not apply live"); + inputCall.resolve(null); + await flush(); + assert.strictEqual(CONFIG.snitch.catchRadius, before, "Revert did not restore the slider"); + + // A call shows each eligible player a card and nothing that takes their screen. + const contextsBefore = contextCalls; + exportsMap.get("call")(admin, "match", "gryffindor", "slytherin", true); + await flush(); + assert.strictEqual(contextCalls, contextsBefore, "calling a match opened a dialog on someone's screen"); + const card = emitted(guest, "quidditch:lobbyCard").at(-1); + assert.strictEqual(card.title, "Open match"); + assert.strictEqual(card.key, "J"); + assert.strictEqual(card.referee, false); + assert.ok(card.remainingMs > 0 && card.teams.length === 2); + assert.strictEqual(emitted(admin, "quidditch:lobbyCard").at(-1).referee, true); + + // The card's key opens the picker, and a pick reaches every card. + uiAnswers.push("join:gryffindor:seeker"); + handlers.get("client:quidditch:lobbyOpen")(guest); + await flush(); + assert.strictEqual(contextCalls, contextsBefore + 1, "the key did not open the picker"); + assert.strictEqual(emitted(guest, "quidditch:lobbyCard").at(-1).pick, "Gryffindor Seeker"); + assert.strictEqual(emitted(admin, "quidditch:lobbyCard").at(-1).teams[0].filled, 1, "the referee's card missed the pick"); + + // Calling it off takes every card down. + await chat(admin, "cancel"); + await flush(); + assert.deepStrictEqual(emitted(guest, "quidditch:lobbyCard").at(-1), { closed: true }); + assert.strictEqual(contextCalls, contextsBefore + 1, "calling off opened a dialog"); + + handlers.get("resourceStop")("hmp-quidditch"); + global.setInterval = realSetInterval; + global.setTimeout = realSetTimeout; + console.log("hmp-quidditch server behaviour passed"); +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/gamemodes/resources/hmp-quidditch/tsconfig.json b/gamemodes/resources/hmp-quidditch/tsconfig.json new file mode 100644 index 0000000..f8f0e08 --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["../../../types/**/*.d.ts"] +} diff --git a/gamemodes/resources/hmp-quidditch/types.d.ts b/gamemodes/resources/hmp-quidditch/types.d.ts new file mode 100644 index 0000000..e61acbe --- /dev/null +++ b/gamemodes/resources/hmp-quidditch/types.d.ts @@ -0,0 +1,23 @@ +export type HmpQuidditchMode = "snitchHunt" | "practice" | "match"; +export type HmpQuidditchHouse = "gryffindor" | "hufflepuff" | "ravenclaw" | "slytherin"; + +export interface HmpQuidditchPlayer { + id: number; + nickname: string; + sendChat?(message: string): void; +} + +export interface HmpQuidditchStatus { + state: "idle" | "forming" | "running" | "ending"; + mode: HmpQuidditchMode | null; + players: number; + activitySessionId: string | null; + dependencies: { activities: string; houses: string; world: string }; +} + +export interface HmpQuidditch

{ + status(): HmpQuidditchStatus; + call(referee: P, mode?: HmpQuidditchMode, teamA?: HmpQuidditchHouse, teamB?: HmpQuidditchHouse, ffa?: boolean): void; + start(starter: P, mode?: HmpQuidditchMode, solo?: boolean): void; + stop(reason?: string): void; +} diff --git a/package-lock.json b/package-lock.json index bf0e0f3..71c3a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,21 @@ "tsx": "4.20.6" } }, + "gamemodes/resources/hmp-gauntlet": { + "version": "0.8.2", + "license": "SEE LICENSE IN LICENSE", + "devDependencies": { + "esbuild": "0.25.12", + "typescript": "5.9.3" + } + }, + "gamemodes/resources/hmp-quidditch": { + "version": "0.8.2", + "license": "SEE LICENSE IN LICENSE", + "devDependencies": { + "esbuild": "0.25.12" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -636,6 +651,10 @@ "resolved": "resources/hmp-emotes", "link": true }, + "node_modules/hmp-gauntlet": { + "resolved": "gamemodes/resources/hmp-gauntlet", + "link": true + }, "node_modules/hmp-houses": { "resolved": "resources/hmp-houses", "link": true @@ -676,6 +695,10 @@ "resolved": "resources/hmp-pvp", "link": true }, + "node_modules/hmp-quidditch": { + "resolved": "gamemodes/resources/hmp-quidditch", + "link": true + }, "node_modules/hmp-shops": { "resolved": "resources/hmp-shops", "link": true diff --git a/resources/hmp-admin/README.md b/resources/hmp-admin/README.md index 64204be..a4d475b 100644 --- a/resources/hmp-admin/README.md +++ b/resources/hmp-admin/README.md @@ -56,8 +56,9 @@ accepts `HMP_ADMIN_COMMAND`, `HMP_ADMIN_REQUIRE_VERIFIED`, and `HMP_ADMIN_CONFIG - Moderation: durable warnings, kick, verified-identity timed or permanent bans, ban review/revoke. - Intervention: acknowledged streamed go-to/bring and Framework-authoritative freeze/release. - Staff movement: audited, server-authorized no-clip from the main menu, with camera-relative WASD, - Space/Ctrl vertical movement, Shift boost, collision bypass, and protected landing. While flying - the admin is hidden from every other player (body, nametag, minimap blip) by the server. + Space/Ctrl vertical movement, Shift boost, Alt slow movement for precise positioning, collision + bypass, and protected landing. While flying the admin is hidden from every other player (body, + nametag, minimap blip) by the server, and can optionally be hidden from their own screen too. - World environment: audited global weather, season, calendar date, 24-hour time, and clock-speed controls, including a searchable dropdown of 34 known native weather profiles and a confirmation-gated reset to the configured `hmp-world` baseline. A custom live or baseline profile is added to the dropdown @@ -76,9 +77,31 @@ registered with `hmp-inventory`; native entries are routed through the Framework The inventory editor searches that catalog on the server first and sends at most 32 exact choices to the client, keeping the dialog below the Framework event-payload limit even with thousands of native items. -No-clip tuning and movement keys can be overridden in the `noclip` object in `data/hmp-admin.json`. -The defaults are `4500` cm/s, a `3x` Shift boost, and QWERTY WASD movement. Set `"hide": false` -to keep the admin visible while flying; a server without `player.setVisible` always does. +No-clip tuning and movement keys can be overridden in the `noclip` object in `data/hmp-admin.json`: + +```json +{ + "noclip": { + "speed": 4500, + "boost": 3, + "slow": 0.25, + "tickMs": 11, + "hide": true, + "hideLocal": false, + "movement": { + "forward": "w", "back": "s", "left": "a", "right": "d", + "up": "space", "down": "ctrl", "boost": "shift", "slow": "alt" + } + } +} +``` + +`speed` is in cm/s. `boost` multiplies it while the boost key is held, and `slow` (from `0.05` to +`1`) while the slow key is held; slow wins when both are held. Set `"hide": false` to keep the admin +visible to other players while flying; a server without `player.setVisible` always does. Set +`"hideLocal": true` to also hide the admin's body, wand, and held prop on their own screen while +flying, which keeps the falling animation out of view. It needs HogwartsMP `1.9` or newer +(`LocalPlayer.setVisibility`) and is restored when no-clip ends. ## Suggested closed-test feedback @@ -94,5 +117,7 @@ or situation where the menu lost context after a player disconnected. - `actions` - `moderation` - `audit` +- `noclip`: `isActive(player | playerId)` reports whether administrator no-clip is switched on for a + connected player, so other resources can react without tracking it themselves. - `ui` - `status` diff --git a/resources/hmp-admin/client/main.ts b/resources/hmp-admin/client/main.ts index 951fe15..8eb8ef2 100644 --- a/resources/hmp-admin/client/main.ts +++ b/resources/hmp-admin/client/main.ts @@ -6,19 +6,22 @@ interface NoclipMovement { up: string; down: string; boost: string; + slow: string; } interface NoclipCommand { enabled: boolean; speed?: number; boost?: number; + slow?: number; tickMs?: number; + hideLocal?: boolean; movement?: NoclipMovement; } const DEFAULT_MOVEMENT: NoclipMovement = { forward: "w", back: "s", left: "a", right: "d", - up: "space", down: "ctrl", boost: "shift", + up: "space", down: "ctrl", boost: "shift", slow: "alt", }; let active = false; @@ -26,7 +29,9 @@ let position: HogwartsMpVector3 | null = null; let movement = DEFAULT_MOVEMENT; let speed = 4500; let boost = 3; +let slow = 0.25; let tickMs = 11; +let hiddenLocally = false; let lastTick = 0; let flightTimer: ReturnType | null = null; let landingTimer: ReturnType | null = null; @@ -49,6 +54,14 @@ function setProtected(protectedFromDamage: boolean): void { catch (_) { /* no local pawn while loading */ } } +// The platform keeps this choice through respawns and travel, so every way out of no-clip restores it. +function setLocallyHidden(hidden: boolean): void { + if (hiddenLocally === hidden) return; + try { LocalPlayer.setVisibility(!hidden); } + catch (_) { /* hosts before HogwartsMP 1.9 have no self-visibility */ } + hiddenLocally = hidden; +} + function getViewYaw(): number { try { const yaw = Number(Camera.capture()?.rotation.yaw); @@ -102,7 +115,8 @@ function tick(): void { } const magnitude = Math.sqrt(x * x + y * y + z * z); if (magnitude > 0) { - const distance = speed * (keyDown(movement.boost) ? boost : 1) * elapsed / magnitude; + const factor = keyDown(movement.slow) ? slow : keyDown(movement.boost) ? boost : 1; + const distance = speed * factor * elapsed / magnitude; position.x += x * distance; position.y += y * distance; position.z += z * distance; } setProtected(true); @@ -121,12 +135,14 @@ function enable(command: NoclipCommand): void { movement = command.movement || DEFAULT_MOVEMENT; speed = Math.max(100, Number(command.speed) || 4500); boost = Math.max(1, Number(command.boost) || 3); + slow = Math.max(0.05, Math.min(1, Number(command.slow) || 0.25)); tickMs = Math.max(5, Math.trunc(Number(command.tickMs)) || 11); position = { x: current.x, y: current.y, z: current.z }; lastTick = 0; active = true; LocalPlayer.setCollision(false); setProtected(true); + setLocallyHidden(command.hideLocal === true); if (flightTimer) clearInterval(flightTimer); flightTimer = setInterval(tick, tickMs); } @@ -139,6 +155,7 @@ function disable(): void { flightTimer = null; try { LocalPlayer.setCollision(true); } catch (_) { /* no local pawn while loading */ } + setLocallyHidden(false); if (wasActive) watchForLanding(); else if (!landingTimer) setProtected(false); } @@ -150,6 +167,7 @@ function stop(): void { if (landingTimer) clearInterval(landingTimer); landingTimer = null; try { LocalPlayer.setCollision(true); } catch (_) { /* no local pawn while loading */ } + setLocallyHidden(false); setProtected(false); } diff --git a/resources/hmp-admin/package.json b/resources/hmp-admin/package.json index 6803de3..6007515 100644 --- a/resources/hmp-admin/package.json +++ b/resources/hmp-admin/package.json @@ -26,7 +26,7 @@ "files": [ "dist/client.js" ], - "exports": ["permissions", "players", "actions", "moderation", "audit", "ui", "status"], + "exports": ["permissions", "players", "actions", "moderation", "audit", "noclip", "ui", "status"], "resourceDependencies": [ { "name": "hmp-mysql", "version": "0.8.2" }, { "name": "hmp-lib", "version": "0.8.2" }, diff --git a/resources/hmp-admin/server/config.ts b/resources/hmp-admin/server/config.ts index 86b451f..f978f36 100644 --- a/resources/hmp-admin/server/config.ts +++ b/resources/hmp-admin/server/config.ts @@ -13,9 +13,11 @@ function loadConfig(Hmp: HmpLibServer, options: { env?: NodeJS.ProcessEn noclip: { speed: 4500, boost: 3, + slow: 0.25, tickMs: 11, hide: true, - movement: { forward: "w", back: "s", left: "a", right: "d", up: "space", down: "ctrl", boost: "shift" }, + hideLocal: false, + movement: { forward: "w", back: "s", left: "a", right: "d", up: "space", down: "ctrl", boost: "shift", slow: "alt" }, }, roleRules: [ { group: "admin", minimumGrade: 1, capabilities: ["admin.view", "admin.kick", "admin.teleport", "admin.freeze", "admin.warn", "admin.announce", "admin.noclip"] }, @@ -38,8 +40,10 @@ function loadConfig(Hmp: HmpLibServer, options: { env?: NodeJS.ProcessEn config.noclip = { speed: Math.max(100, Math.min(50000, Number(noclip.speed) || defaults.noclip.speed)), boost: Math.max(1, Math.min(10, Number(noclip.boost) || defaults.noclip.boost)), + slow: Math.max(0.05, Math.min(1, Number(noclip.slow) || defaults.noclip.slow)), tickMs: Math.max(5, Math.min(100, Math.trunc(Number(noclip.tickMs)) || defaults.noclip.tickMs)), hide: noclip.hide !== false, + hideLocal: noclip.hideLocal === true, movement: { forward: String(movement.forward || defaults.noclip.movement.forward), back: String(movement.back || defaults.noclip.movement.back), @@ -48,6 +52,7 @@ function loadConfig(Hmp: HmpLibServer, options: { env?: NodeJS.ProcessEn up: String(movement.up || defaults.noclip.movement.up), down: String(movement.down || defaults.noclip.movement.down), boost: String(movement.boost || defaults.noclip.movement.boost), + slow: String(movement.slow || defaults.noclip.movement.slow), }, }; if (!Array.isArray(config.roleRules)) throw new TypeError("hmp-admin roleRules must be an array"); diff --git a/resources/hmp-admin/server/internal.ts b/resources/hmp-admin/server/internal.ts index 3e35946..bdb396c 100644 --- a/resources/hmp-admin/server/internal.ts +++ b/resources/hmp-admin/server/internal.ts @@ -25,9 +25,13 @@ export type Logger = Pick; export interface AdminNoclipConfig { speed: number; boost: number; + /** Speed multiplier while the slow key is held; wins over boost when both are held. */ + slow: number; tickMs: number; /** Hide the admin from every other player while flying (player.setVisible). Default true. */ hide?: boolean; + /** Hide the admin's body from their own screen while flying (LocalPlayer.setVisibility). Default false. */ + hideLocal?: boolean; movement: { forward: string; back: string; @@ -36,6 +40,7 @@ export interface AdminNoclipConfig { up: string; down: string; boost: string; + slow: string; }; } diff --git a/resources/hmp-admin/server/main.ts b/resources/hmp-admin/server/main.ts index 60cb7a2..d796d72 100644 --- a/resources/hmp-admin/server/main.ts +++ b/resources/hmp-admin/server/main.ts @@ -36,7 +36,7 @@ const admin = createAdminService({ }); const adminUi = createAdminUi({ admin, ui, banking, inventory, spells }); -for (const name of ["permissions", "players", "actions", "moderation", "audit", "status"] as const) Exports.register(name, admin[name]); +for (const name of ["permissions", "players", "actions", "moderation", "audit", "noclip", "status"] as const) Exports.register(name, admin[name]); Exports.register("ui", adminUi); Events.on("hmp:session:ready", (session: unknown) => { diff --git a/resources/hmp-admin/server/service.ts b/resources/hmp-admin/server/service.ts index 3c3cd1d..8892a04 100644 --- a/resources/hmp-admin/server/service.ts +++ b/resources/hmp-admin/server/service.ts @@ -255,6 +255,13 @@ function createAdminService(options: { catch (error) { logger.warn(`Could not ${hidden ? "hide" : "reveal"} player #${player?.id}: ${error instanceof Error ? error.message : String(error)}`); } } + const noclip = Object.freeze({ + isActive(player: Player | number): boolean { + const id = typeof player === "number" ? Math.trunc(player) : player?.id; + return typeof id === "number" && activeNoclip.has(id); + }, + }); + const actions = Object.freeze({ environment, async noclip(actor: Player, reason = "Toggle administrator no-clip") { @@ -502,6 +509,7 @@ function createAdminService(options: { actions, moderation, audit, + noclip, ready: () => startPromise, onSessionReady, onTeleportComplete, diff --git a/resources/hmp-admin/server/ui.ts b/resources/hmp-admin/server/ui.ts index c006473..475208e 100644 --- a/resources/hmp-admin/server/ui.ts +++ b/resources/hmp-admin/server/ui.ts @@ -588,7 +588,7 @@ function createAdminUi(options: { admin: AdminService; ui: Ui; banking: Banking; const capabilities = await admin.permissions.capabilities(player); const menu = [{ id: "players", title: "Connected players", description: "Inspect, moderate, and correct a connected account." }]; if (allowed(capabilities, "admin.environment")) menu.push({ id: "environment", title: "World environment", description: "Change global weather, season, date, time, and clock speed." }); - if (allowed(capabilities, "admin.noclip")) menu.push({ id: "noclip", title: "Toggle no-clip", description: "Fly through geometry with WASD, Space/Ctrl, and Shift to boost." }); + if (allowed(capabilities, "admin.noclip")) menu.push({ id: "noclip", title: "Toggle no-clip", description: "Fly through geometry with WASD, Space/Ctrl, Shift to boost, and Alt to slow down." }); if (allowed(capabilities, "admin.reconcile")) menu.push({ id: "reconcile", title: "Pending banking recovery", description: "Resolve transactions left in a pending state." }); if (allowed(capabilities, "admin.ban")) menu.push({ id: "bans", title: "Ban records", description: "Review and revoke identity bans." }); if (allowed(capabilities, "admin.audit")) menu.push({ id: "audit", title: "Administrative audit", description: "Review recent successful, failed, and pending actions." }); @@ -599,7 +599,7 @@ function createAdminUi(options: { admin: AdminService; ui: Ui; banking: Banking; else if (choice === "noclip") { try { const enabled = await admin.actions.noclip(player); - ui.notify(player, { title: `No-clip ${enabled ? "enabled" : "disabled"}`, description: enabled ? "WASD to move, Space/Ctrl for height, Shift to boost. Reopen /admin to turn it off." : "Collision restored; fall protection remains until landing.", tone: enabled ? "success" : "inform", duration: 7000 }); + ui.notify(player, { title: `No-clip ${enabled ? "enabled" : "disabled"}`, description: enabled ? "WASD to move, Space/Ctrl for height, Shift to boost, Alt to slow down. Reopen /admin to turn it off." : "Collision restored; fall protection remains until landing.", tone: enabled ? "success" : "inform", duration: 7000 }); if (enabled) break; } catch (error) { notifyError(player, error); } } diff --git a/resources/hmp-admin/test/admin.test.ts b/resources/hmp-admin/test/admin.test.ts index bc74746..c0ba2e7 100644 --- a/resources/hmp-admin/test/admin.test.ts +++ b/resources/hmp-admin/test/admin.test.ts @@ -1,11 +1,13 @@ import assert = require("node:assert"); import { test } from "node:test"; +import configModule = require("../server/config"); import permissionsModule = require("../server/permissions"); import serviceModule = require("../server/service"); import uiModule = require("../server/ui"); import type { HmpAdminAuditEntry, HmpAdminBan, HmpAdminWarning } from "../types"; import type { AdminConfig, AdminRepository, Player } from "../server/internal"; +const { loadConfig } = configModule; const { createPermissions } = permissionsModule; const { createAdminService } = serviceModule; const { inventoryOptions, inventoryItemField, spellOptions, transmogOptions, weatherOptions, WEATHER_PROFILES } = uiModule; @@ -155,7 +157,7 @@ function setup() { const config: AdminConfig = { command: "admin", requireVerifiedIdentity: true, allowUnsafeAssertedBans: false, teleportTimeoutMs: 5000, auditPageSize: 50, - noclip: { speed: 4500, boost: 3, tickMs: 11, movement: { forward: "w", back: "s", left: "a", right: "d", up: "space", down: "ctrl", boost: "shift" } }, + noclip: { speed: 4500, boost: 3, slow: 0.25, tickMs: 11, hideLocal: true, movement: { forward: "w", back: "s", left: "a", right: "d", up: "space", down: "ctrl", boost: "shift", slow: "alt" } }, bootstrapSecret: "closed-test-secret", roleRules: [ { group: "admin", minimumGrade: 1, capabilities: ["admin.view", "admin.kick", "admin.teleport", "admin.freeze", "admin.warn", "admin.noclip"] }, { group: "admin", minimumGrade: 2, capabilities: ["admin.groups", "admin.jobs", "admin.inventory", "admin.appearance", "admin.spells", "admin.banking", "admin.treasury", "admin.audit", "admin.environment"] }, @@ -290,10 +292,30 @@ test("changes and resets the global environment with validation and audit", asyn assert.ok(state.audit.every((entry) => entry.status === "completed")); }); +test("loads no-clip slow movement and local hiding with safe defaults", () => { + const Hmp = { config: { load: (_path: string, options: { defaults: unknown }) => structuredClone(options.defaults), env: { boolean: (_value: unknown, fallback: boolean) => fallback } } }; + const defaults = loadConfig(Hmp as never, { env: {} }).noclip; + assert.strictEqual(defaults.slow, 0.25); + assert.strictEqual(defaults.hideLocal, false); + assert.strictEqual(defaults.movement.slow, "alt"); + + const overrides = { config: { ...Hmp.config, load: () => ({ roleRules: [], noclip: { slow: 0.001, hideLocal: true, movement: { slow: "lalt" } } }) } }; + const tuned = loadConfig(overrides as never, { env: {} }).noclip; + assert.strictEqual(tuned.slow, 0.05); + assert.strictEqual(tuned.hideLocal, true); + assert.strictEqual(tuned.movement.slow, "lalt"); + assert.strictEqual(tuned.movement.boost, "shift"); + assert.strictEqual(loadConfig({ config: { ...Hmp.config, load: () => ({ roleRules: [], noclip: { slow: 4 } }) } } as never, { env: {} }).noclip.slow, 1); +}); + test("toggles and revokes audited administrator no-clip", async () => { const state = setup(); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin), false); assert.strictEqual(await state.service.actions.noclip(state.verifiedAdmin), true); assert.strictEqual(state.service.status().activeNoclip, 1); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin), true); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin.id), true); + assert.strictEqual(state.service.noclip.isActive(state.verifiedTarget), false); const enabled = state.emitted.get(state.verifiedAdmin.id)![0]; assert.strictEqual(enabled.event, "hmp-admin:noclip"); assert.deepStrictEqual(JSON.parse(String(enabled.payload)), { enabled: true, ...state.config.noclip }); @@ -301,6 +323,7 @@ test("toggles and revokes audited administrator no-clip", async () => { assert.strictEqual(await state.service.actions.noclip(state.verifiedAdmin), false); assert.strictEqual(state.service.status().activeNoclip, 0); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin), false); assert.strictEqual(state.visibility.get(state.verifiedAdmin.id), true); assert.deepStrictEqual(state.audit.map((entry) => entry.action), ["player.noclip.enable", "player.noclip.disable"]); @@ -308,8 +331,15 @@ test("toggles and revokes audited administrator no-clip", async () => { state.groupGrades.delete(state.verifiedAdmin.id); assert.strictEqual(await state.service.revalidateNoclip(), 1); assert.strictEqual(state.service.status().activeNoclip, 0); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin.id), false); assert.strictEqual(state.visibility.get(state.verifiedAdmin.id), true); assert.deepStrictEqual(JSON.parse(String(state.emitted.get(state.verifiedAdmin.id)!.at(-1)!.payload)), { enabled: false }); + + state.groupGrades.set(state.verifiedAdmin.id, 3); + await state.service.actions.noclip(state.verifiedAdmin); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin), true); + state.service.disconnect(state.verifiedAdmin); + assert.strictEqual(state.service.noclip.isActive(state.verifiedAdmin), false); }); test("inspects, freezes, warns, kicks, and audits player actions", async () => { diff --git a/resources/hmp-admin/test/bundle.test.js b/resources/hmp-admin/test/bundle.test.js index d5f0486..f90119c 100644 --- a/resources/hmp-admin/test/bundle.test.js +++ b/resources/hmp-admin/test/bundle.test.js @@ -53,7 +53,8 @@ delete process.env.HMP_ADMIN_BOOTSTRAP_SECRET; require(path.resolve(__dirname, "..", "dist", "server.js")); -assert.deepStrictEqual([...exportsSeen.keys()], ["permissions", "players", "actions", "moderation", "audit", "status", "ui"]); +assert.deepStrictEqual([...exportsSeen.keys()], ["permissions", "players", "actions", "moderation", "audit", "noclip", "status", "ui"]); +assert.strictEqual(exportsSeen.get("noclip").isActive(1), false); assert.ok(handlers.has("hmp:session:ready")); assert.ok(handlers.has("hmp:session:ended")); assert.ok(handlers.has("hmp:groups:changed")); @@ -62,6 +63,51 @@ assert.ok(handlers.has("playerTeleportComplete")); assert.ok(handlers.has("chatCommand")); assert.ok(handlers.has("resourceStart")); assert.ok(handlers.has("resourceStop")); +const clientHandlers = new Map(); +const intervals = []; +const keys = new Set(); +const positions = []; +const visibility = []; +global.Events = { on: (name, handler) => clientHandlers.set(name, handler) }; +global.setInterval = (callback, ms) => { intervals.push({ callback, ms }); return intervals.length; }; +global.clearInterval = () => {}; +global.Key = { isDown: (key) => keys.has(key) }; +global.Game = { areControlsLocked: () => false, notify() {} }; +global.Camera = { capture: () => ({ rotation: { pitch: 0, yaw: 0, roll: 0 } }) }; +global.LocalPlayer = { + getPosition: () => ({ x: 0, y: 0, z: 0 }), + setPosition: (x, y, z) => { positions.push({ x, y, z }); return true; }, + setCollision: () => true, + clearVelocity: () => true, + getControlRotation: () => ({ pitch: 0, yaw: 0, roll: 0 }), + setVisibility: (visible) => { visibility.push(visible); return true; }, + stateInfo: { setInvulnerableToDamage: () => true }, +}; +require(path.resolve(__dirname, "..", "dist", "client.js")); +assert.ok(clientHandlers.has("hmp-admin:noclip")); +assert.ok(clientHandlers.has("resourceStop")); +const movement = { forward: "w", back: "s", left: "a", right: "d", up: "space", down: "ctrl", boost: "shift", slow: "alt" }; +function flyOneTick(held, options = {}) { + clientHandlers.get("hmp-admin:noclip")(JSON.stringify({ enabled: true, speed: 1000, boost: 3, slow: 0.25, tickMs: 10, movement, ...options })); + keys.clear(); + for (const key of held) keys.add(key); + intervals.at(-1).callback(); + return positions.at(-1).x; +} +assert.strictEqual(flyOneTick(["w"]), 10); +assert.strictEqual(flyOneTick(["w", "shift"]), 30); +assert.strictEqual(flyOneTick(["w", "alt"]), 2.5); +assert.strictEqual(flyOneTick(["w", "shift", "alt"]), 2.5); +clientHandlers.get("hmp-admin:noclip")(JSON.stringify({ enabled: false })); +assert.deepStrictEqual(visibility, [], "local hiding is opt-in"); +flyOneTick([], { hideLocal: true }); +assert.deepStrictEqual(visibility, [false]); +clientHandlers.get("hmp-admin:noclip")(JSON.stringify({ enabled: false })); +assert.deepStrictEqual(visibility, [false, true]); +flyOneTick([], { hideLocal: true }); +clientHandlers.get("resourceStop")("hmp-admin"); +assert.deepStrictEqual(visibility, [false, true, false, true]); + void (async () => { await handlers.get("resourceStart")("hmp-admin"); assert.strictEqual(exportsSeen.get("status")().state, "ready"); diff --git a/resources/hmp-admin/types.d.ts b/resources/hmp-admin/types.d.ts index 79ae261..fddab46 100644 --- a/resources/hmp-admin/types.d.ts +++ b/resources/hmp-admin/types.d.ts @@ -154,6 +154,11 @@ export interface HmpAdminAuditApi

{ history(actor: P, limit?: number, targetAccountId?: number): Promise; } +export interface HmpAdminNoclipApi

{ + /** Whether the server has administrator no-clip switched on for a connected player or player id. */ + isActive(player: P | number): boolean; +} + export interface HmpAdminUiApi

{ open(player: P): Promise; close(player: P): boolean } export interface HmpAdminStatus { @@ -172,6 +177,7 @@ export interface HmpAdmin

{ actions: HmpAdminActionsApi

; moderation: HmpAdminModerationApi

; audit: HmpAdminAuditApi

; + noclip: HmpAdminNoclipApi

; ui: HmpAdminUiApi

; status(): HmpAdminStatus; } diff --git a/resources/hmp-duels/README.md b/resources/hmp-duels/README.md index 61913b5..4ae6065 100644 --- a/resources/hmp-duels/README.md +++ b/resources/hmp-duels/README.md @@ -2,23 +2,24 @@ `hmp-duels` is Foundations's consensual, non-lethal one-on-one wizard duel resource. A challenge is a private `hmp-activities` invitation; accepting creates an exclusive two-player session scoped to the -same game area and virtual world. `hmp-pvp` then authorizes only that agreed pair while allowing other -PvP resources to own their own matches or zones. +same game area and virtual world. Its `hmp-pvp` rule governs only that agreed pair, leaving other PvP +resources to own their own matches or zones. -The duel rule has higher priority than `hmp-pvp`'s open-world lethal fallback. Duel participants therefore -remain floored and kneel-finished even when staff enable lethal mode for everyone outside the pair. When a -duel ends, its client presentation restores the current `hmp-pvp` targeting/team snapshot rather than -blindly clearing a server-wide lethal relationship. +An accepted duel is a host pair override (`Pvp.setPair`): peaceful through the countdown, then hostile +with lock-on and a kneel finish, whatever the server's own PvP rules say. The host decides who may hurt +whom for both clients, and natural health recovery is held off for the fight. When the duel ends the +override is cleared and the two return to the server's own rules. -The client uses the native `Pvp` duel context, targetability gate, hostile relationship, opponent -meter, health-recovery control, and retail near-death kneel. Damage remains server-arbitrated. The KO -decision returns `minHp`, so the victim applies the floor against live health instead of trusting the -server's potentially stale vitals report. +The duel rule has higher priority than `hmp-pvp`'s open-world lethal fallback, so participants remain +floored and kneel-finished even while staff have lethal mode on for everyone else. Each hit is floored at +`koHp` on the victim's live health. The victim's own client kneels on the finishing hit and the host +reports it as `pvpDefeat`, which ends the duel; nothing trusts the server's possibly stale vitals report. +The client only presents the countdown, the opponent's health meter and the result. -Commands: +Commands (a player id takes a `#`, as in `/duel #5`): ```text -/duel +/duel <#playerId|exact nickname> /duel accept /duel decline /duel cancel diff --git a/resources/hmp-duels/client/main.ts b/resources/hmp-duels/client/main.ts index b209ebf..d8f2273 100644 --- a/resources/hmp-duels/client/main.ts +++ b/resources/hmp-duels/client/main.ts @@ -1,16 +1,10 @@ -import type { HmpPvpClient } from "../../hmp-pvp/types"; - -const pvpFoundations = Imports.get("hmp-pvp"); +// Who may hurt whom, and the loser's kneel, are the server's pair override and the native PvP pipeline; +// this client only presents the countdown, the opponent's meter and the result. let countdownTimer: ReturnType | null = null; -let restoreTimer: ReturnType | null = null; function reset(): void { if (countdownTimer) clearInterval(countdownTimer); countdownTimer = null; - if (restoreTimer) clearTimeout(restoreTimer); restoreTimer = null; - try { - Pvp.endDuelContext(); Pvp.showMeter(null); pvpFoundations.mode.restore(); - LocalPlayer.stateInfo.setInvulnerableToDamage(false); - } catch (_) { /* no pawn while loading */ } + try { Pvp.showMeter(null); } catch (_) { /* no HUD while loading */ } } function parse(raw: unknown): Record { @@ -19,29 +13,27 @@ function parse(raw: unknown): Record { } reset(); +// The server's own timer, not these numbers, makes the pair hostile. Events.on("hmp-duels:countdown", (raw: unknown) => { - const payload = parse(raw); const opponentId = Number(payload.opponentId) || 0; - Pvp.startDuelContext(); Pvp.setTargetable(opponentId); Pvp.setTeam("Enemy", opponentId); Pvp.showMeter(opponentId); - LocalPlayer.stateInfo.setInvulnerableToDamage(true); + const payload = parse(raw); + Pvp.showMeter(Number(payload.opponentId) || 0); let remaining = Math.max(1, Number(payload.seconds) || 3); Game.notify(`Duel against ${String(payload.opponentName || "opponent")} begins in ${remaining}…`); if (countdownTimer) clearInterval(countdownTimer); countdownTimer = setInterval(() => { remaining--; if (remaining <= 0) { clearInterval(countdownTimer!); countdownTimer = null; } else Game.notify(String(remaining)); }, 1000); }); Events.on("hmp-duels:start", (raw: unknown) => { - const payload = parse(raw); const opponentId = Number(payload.opponentId) || 0; - Pvp.startDuelContext(); Pvp.setTargetable(opponentId); Pvp.setTeam("Enemy", opponentId); Pvp.showMeter(opponentId); - if (countdownTimer) clearInterval(countdownTimer); countdownTimer = null; Game.notify("Duel!"); + Pvp.showMeter(Number(parse(raw).opponentId) || 0); + if (countdownTimer) clearInterval(countdownTimer); countdownTimer = null; + Game.notify("Duel!"); }); +// A loser is already kneeling: the finishing hit's own defeat, which stands them back up by itself. Events.on("hmp-duels:end", (raw: unknown) => { const payload = parse(raw); const result = String(payload.result || "cancelled"); - if (result === "lose") { - const kneel = Pvp.kneel(); Events.emitServer("hmp-duels:kneel-result", JSON.stringify({ result: kneel })); - Game.notify(`Defeated — ${String(payload.opponentName || "your opponent")} wins`); - restoreTimer = setTimeout(reset, 1500); return; - } + const opponent = String(payload.opponentName || "your opponent"); reset(); - Game.notify(result === "win" ? `Victory over ${String(payload.opponentName || "your opponent")}!` : `Duel ended: ${String(payload.reason || result)}`); + if (result === "lose") Game.notify(`Defeated — ${opponent} wins`); + else Game.notify(result === "win" ? `Victory over ${opponent}!` : `Duel ended: ${String(payload.reason || result)}`); }); Events.on("resourceStop", (name?: string) => { if (!name || name === "hmp-duels") reset(); }); console.info("[hmp-duels] native duel coordinator ready"); diff --git a/resources/hmp-duels/server/main.ts b/resources/hmp-duels/server/main.ts index 1f5f1e4..60ed857 100644 --- a/resources/hmp-duels/server/main.ts +++ b/resources/hmp-duels/server/main.ts @@ -21,6 +21,8 @@ const koHp = Math.max(1, Math.min(100, Number(loaded.koHp) || 1)); const duels = createDuelService({ activities, pvp, countdownSeconds, invitationTtlSeconds, koHp, player: (id) => PlayerManager.getById(id), + pair: (a, b, options) => Pvp.setPair(a, b, options), + setRecovery: (id, on) => { try { PlayerManager.getById(id)?.setHealthRecovery?.(on); } catch (_) { /* left mid-duel */ } }, emit: (player, event, payload) => player.emit(event, JSON.stringify(payload)), notify: (player, message, tone = "inform") => { ui.notify(player, { title: "Wizard's duel", description: message, tone }); }, warn: (message) => logger.warn(message), @@ -51,7 +53,7 @@ if (loaded.enableCommands !== false) Events.on("chatCommand", (player: HmpDuelPl if (action === "forfeit") { reply(await duels.forfeit(player) ? "duel forfeited" : "you are not in a duel"); return; } if (action === "status") { const status = duels.status(); reply(`${status.pending} pending, ${status.countdowns} countdown, ${status.live} live`); return; } const target = action ? Hmp.player.find(args.join(" "), player) : null; - if (!target || target.id === player.id) { reply(`Usage: /${command} | accept | decline | cancel | forfeit | status`); return; } + if (!target || target.id === player.id) { reply(`Usage: /${command} <#playerId|nickname> | accept | decline | cancel | forfeit | status`); return; } const challenge = await duels.challenge(player, target); reply(`challenge sent to ${target.nickname}; expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`); void promptChallenge(target, player).catch((error) => logger.warn(`Challenge prompt failed: ${error instanceof Error ? error.message : String(error)}`)); @@ -59,6 +61,7 @@ if (loaded.enableCommands !== false) Events.on("chatCommand", (player: HmpDuelPl }); Events.on("hmp:activities:invitation:expired", (payload: unknown) => { void duels.expired(payload); }); +Events.on("pvpDefeat", (victim: HmpDuelPlayer, killer: HmpDuelPlayer) => { void duels.defeated(victim, killer); }); Events.on("playerDied", (player: HmpDuelPlayer) => { void duels.died(player); }); Events.on("playerDisconnect", (player: HmpDuelPlayer) => { void duels.disconnected(player); }); Events.on("resourceStop", (name?: string) => { if (!name || name === "hmp-duels") void duels.stop(); }); diff --git a/resources/hmp-duels/server/service.ts b/resources/hmp-duels/server/service.ts index 72b3bcc..f128692 100644 --- a/resources/hmp-duels/server/service.ts +++ b/resources/hmp-duels/server/service.ts @@ -6,6 +6,9 @@ interface Dependencies

{ activities: HmpActivities

; pvp: HmpPvp; player(id: number): P | null; + /** The host's pair override (Pvp.setPair); null clears it. False when the host has no room. */ + pair(a: number, b: number, options: { hostile: boolean; aim?: "lock" | "skillshot"; defeat?: "death" | "kneel" } | null): boolean; + setRecovery(playerId: number, on: boolean): void; emit(player: P, event: string, payload: Record): void; notify(player: P, message: string, tone?: "inform" | "success" | "warning" | "error"): void; countdownSeconds: number; @@ -17,7 +20,13 @@ interface Dependencies

{ warn?(message: string): void; } +// A live duel is a pair override: lock-on and a kneel finish, whatever the server's own rules say. +const DUEL = { hostile: true, aim: "lock", defeat: "kneel" } as const; +// The countdown reserves the pair but holds it peaceful, even on an open-PvP server. +const COUNTDOWN = { hostile: false } as const; + function createDuelService

(dependencies: Dependencies

): HmpDuels

& { + defeated(victim: P, killer: P): Promise; expired(payload: unknown): Promise; died(player: P): Promise; disconnected(player: P): Promise; @@ -48,8 +57,14 @@ function createDuelService

(dependencies: Dependencies

active.has(id)); for (const id of ids) active.delete(id); - if (ids.length >= 2) { live.delete(pairKey(ids[0], ids[1])); ending.delete(pairKey(ids[0], ids[1])); } + for (const id of began) dependencies.setRecovery(id, true); + if (ids.length >= 2) { + live.delete(pairKey(ids[0], ids[1])); ending.delete(pairKey(ids[0], ids[1])); + if (began.length) dependencies.pair(ids[0], ids[1], null); + } const winnerId = Number(result?.winnerId) || 0; const loserId = Number(result?.loserId) || 0; for (const participant of session.participants) { @@ -76,6 +91,8 @@ function createDuelService

(dependencies: Dependencies

) { @@ -83,12 +100,6 @@ function createDuelService

(dependencies: Dependencies

= hit.victimHp - dependencies.koHp) { - ending.add(key); live.delete(key); - void activities.sessions.complete(match.sessionId, { winnerId: hit.casterId, loserId: hit.victimId, reason: "ko" }, { resource: "hmp-duels", reason: "ko" }) - .catch((error) => { ending.delete(key); dependencies.warn?.(`Could not finish duel KO: ${error instanceof Error ? error.message : String(error)}`); }); - return { damage: Math.max(0, hit.victimHp - dependencies.koHp), minHp: dependencies.koHp }; - } return { minHp: dependencies.koHp }; }, }); @@ -96,19 +107,24 @@ function createDuelService

(dependencies: Dependencies

{ const [left, right] = session.participants; if (!left || !right) throw new Error("A duel requires exactly two players"); + if (!dependencies.pair(left.playerId, right.playerId, COUNTDOWN)) throw new Error("The server has no room for another duel right now"); active.set(left.playerId, { opponentId: right.playerId, sessionId: session.id }); active.set(right.playerId, { opponentId: left.playerId, sessionId: session.id }); + dependencies.setRecovery(left.playerId, false); + dependencies.setRecovery(right.playerId, false); emitTo(left.playerId, "hmp-duels:countdown", { seconds: dependencies.countdownSeconds, opponentId: right.playerId, opponentName: right.nickname }); emitTo(right.playerId, "hmp-duels:countdown", { seconds: dependencies.countdownSeconds, opponentId: left.playerId, opponentName: left.nickname }); + // Not unref'd: each resource runs its own event loop, and libuv skips the timers of a loop with nothing referenced. const timer = schedule(() => { countdowns.delete(session.id); const current = activities.sessions.get(session.id); if (!current || active.get(left.playerId)?.opponentId !== right.playerId) return; live.add(pairKey(left.playerId, right.playerId)); + dependencies.pair(left.playerId, right.playerId, DUEL); emitTo(left.playerId, "hmp-duels:start", { opponentId: right.playerId, opponentName: right.nickname }); emitTo(right.playerId, "hmp-duels:start", { opponentId: left.playerId, opponentName: left.nickname }); }, dependencies.countdownSeconds * 1000); - timer.unref?.(); countdowns.set(session.id, timer); + countdowns.set(session.id, timer); } async function challenge(challenger: P, target: P): Promise { @@ -166,6 +182,13 @@ function createDuelService

(dependencies: Dependencies

{ + const victimId = Number(victim.id), killerId = Number(killer.id); + if (active.get(victimId)?.opponentId !== killerId || !live.has(pairKey(victimId, killerId))) return; + await finish(killerId, victimId, "ko"); + } + async function died(player: P): Promise { const match = active.get(Number(player.id)); if (match) await finish(match.opponentId, Number(player.id), "died"); } async function disconnected(player: P): Promise { const match = active.get(Number(player.id)); if (match) await finish(match.opponentId, Number(player.id), "disconnect"); } async function stop(): Promise { @@ -174,7 +197,7 @@ function createDuelService

(dependencies: Dependencies

answer(player, true), decline: (player: P, reason?: string) => answer(player, false, reason), cancel, forfeit, status: () => ({ state, pending: activities.invitations.list().filter((entry) => entry.activityId === "duel:standard").length, countdowns: countdowns.size, live: live.size, uptimeMs: now() - startedAt }), expired, died, disconnected, stop }); + return Object.freeze({ challenge, accept: (player: P) => answer(player, true), decline: (player: P, reason?: string) => answer(player, false, reason), cancel, forfeit, status: () => ({ state, pending: activities.invitations.list().filter((entry) => entry.activityId === "duel:standard").length, countdowns: countdowns.size, live: live.size, uptimeMs: now() - startedAt }), defeated, expired, died, disconnected, stop }); } export = { createDuelService }; diff --git a/resources/hmp-duels/test/bundle.test.js b/resources/hmp-duels/test/bundle.test.js index 04d56b1..a297fc6 100644 --- a/resources/hmp-duels/test/bundle.test.js +++ b/resources/hmp-duels/test/bundle.test.js @@ -14,17 +14,18 @@ global.PlayerManager = { getById: () => player }; global.Events = { on: (name, handler) => serverHandlers.set(name, handler) }; require(path.resolve(__dirname, "..", "dist", "server.js")); assert.deepStrictEqual([...serverExports.keys()], ["challenge", "accept", "decline", "cancel", "forfeit", "status"]); -assert.ok(serverHandlers.has("playerDied")); assert.ok(serverHandlers.has("hmp:activities:invitation:expired")); +assert.ok(serverHandlers.has("playerDied")); assert.ok(serverHandlers.has("pvpDefeat")); assert.ok(serverHandlers.has("hmp:activities:invitation:expired")); -const clientHandlers = new Map(), calls = []; +// The client may only use the host's current client Pvp surface: the opponent meter. +const clientHandlers = new Map(), calls = [], meters = []; global.Events = { on: (name, handler) => clientHandlers.set(name, handler), emitServer() {} }; -global.Pvp = { endDuelContext() {}, setTargetable() {}, resetTeam() {}, showMeter() {}, startDuelContext() {}, setTeam() {}, kneel: () => "ok" }; -let restored = 0; -global.Imports = { get: () => ({ mode: { restore: () => { restored++; return true; } } }) }; -global.LocalPlayer = { stateInfo: { setInvulnerableToDamage() {} } }; global.Game = { notify: (value) => calls.push(value) }; +global.Pvp = { showMeter: (id) => { meters.push(id); return "ok"; } }; +delete global.Imports; delete global.LocalPlayer; global.Game = { notify: (value) => calls.push(value) }; require(path.resolve(__dirname, "..", "dist", "client.js")); assert.ok(clientHandlers.has("hmp-duels:countdown")); assert.ok(clientHandlers.has("hmp-duels:end")); +clientHandlers.get("hmp-duels:countdown")({ seconds: 3, opponentId: 2, opponentName: "Smoke" }); +clientHandlers.get("hmp-duels:start")({ opponentId: 2, opponentName: "Smoke" }); clientHandlers.get("hmp-duels:end")({ result: "win", opponentName: "Smoke" }); assert.ok(calls.some((value) => String(value).includes("Victory"))); -assert.ok(restored >= 2); +assert.deepStrictEqual(meters, [null, 2, 2, null]); console.log("hmp-duels bundle contract passed"); diff --git a/resources/hmp-duels/test/duels.test.ts b/resources/hmp-duels/test/duels.test.ts index ce051ac..1d24330 100644 --- a/resources/hmp-duels/test/duels.test.ts +++ b/resources/hmp-duels/test/duels.test.ts @@ -19,7 +19,17 @@ async function run(): Promise { const pvp = createPvpService({ defaultDecision: "deny", initialLethalMode: false, install: (fn) => { nativePolicy = fn; }, clearPolicy: () => { nativePolicy = null; }, vitals: () => null, applyMode: () => undefined }); const timers: Array<() => void> = []; const emitted: Array<{ player: number; event: string; payload: Record }> = []; - const duels = createDuelService({ activities, pvp, player: (id) => players.find((entry) => entry.id === id) || null, emit: (player, event, payload) => emitted.push({ player: player.id, event, payload }), notify() {}, countdownSeconds: 3, invitationTtlSeconds: 30, koHp: 1, setTimer: ((handler: () => void) => { timers.push(handler); return { unref() {} } as NodeJS.Timeout; }), clearTimer() {} }); + const pairs: Array<[number, number, unknown]> = []; + const recovery: Array<[number, boolean]> = []; + let pairRoom = true; + const duels = createDuelService({ + activities, pvp, player: (id) => players.find((entry) => entry.id === id) || null, + pair: (a, b, options) => { pairs.push([a, b, options]); return pairRoom || options === null; }, + setRecovery: (id, on) => { recovery.push([id, on]); }, + emit: (player, event, payload) => emitted.push({ player: player.id, event, payload }), notify() {}, + countdownSeconds: 3, invitationTtlSeconds: 30, koHp: 1, + setTimer: ((handler: () => void) => { timers.push(handler); return {} as NodeJS.Timeout; }), clearTimer() {}, + }); const challenge = await duels.challenge(players[0], players[1]); assert.strictEqual(activities.invitations.list().length, 1); @@ -27,15 +37,32 @@ async function run(): Promise { await assert.rejects(duels.challenge(players[2], players[3]), /game area/); assert.strictEqual(await duels.accept(players[1]), true); assert.strictEqual(activities.sessions.get(challenge.sessionId)?.state, "running"); + // The countdown reserves the pair peaceful and holds recovery off. + assert.deepStrictEqual(pairs, [[1, 2, { hostile: false }]]); + assert.deepStrictEqual(recovery, [[1, false], [2, false]]); const hit = { casterId: 1, victimId: 2, spellId: 1, spell: "BasicCast", damage: 10, duration: 0, victimHp: 20 }; assert.strictEqual(nativePolicy!(hit), false); timers.shift()!(); + assert.deepStrictEqual(pairs.at(-1), [1, 2, { hostile: true, aim: "lock", defeat: "kneel" }]); assert.deepStrictEqual(nativePolicy!(hit), { minHp: 1 }); - assert.deepStrictEqual(nativePolicy!({ ...hit, damage: 25 }), { damage: 19, minHp: 1 }); - await new Promise((resolve) => setTimeout(resolve, 0)); + // A finishing hit is only floored; the victim's own kneel, reported as pvpDefeat, ends the duel. + assert.deepStrictEqual(nativePolicy!({ ...hit, damage: 25 }), { minHp: 1 }); + assert.ok(activities.sessions.get(challenge.sessionId)); + await duels.defeated(players[2], players[0]); + assert.ok(activities.sessions.get(challenge.sessionId)); + await duels.defeated(players[1], players[0]); assert.strictEqual(activities.sessions.get(challenge.sessionId), null); assert.ok(emitted.some((entry) => entry.event === "hmp-duels:end" && entry.payload.result === "win")); assert.ok(emitted.some((entry) => entry.event === "hmp-duels:end" && entry.payload.result === "lose")); + assert.deepStrictEqual(pairs.at(-1), [1, 2, null]); + assert.deepStrictEqual(recovery.slice(-2), [[1, true], [2, true]]); + + // A host with no room for another pair refuses the duel instead of starting an unprotected one. + pairRoom = false; + const refused = await duels.challenge(players[0], players[1]); + await assert.rejects(duels.accept(players[1]), /no room/); + assert.strictEqual(activities.sessions.get(refused.sessionId), null); + pairRoom = true; await duels.challenge(players[0], players[1]); assert.strictEqual(await duels.decline(players[1]), true); diff --git a/resources/hmp-emotes/client/menu.html b/resources/hmp-emotes/client/menu.html index d79c90e..9efdad9 100644 --- a/resources/hmp-emotes/client/menu.html +++ b/resources/hmp-emotes/client/menu.html @@ -431,7 +431,8 @@

Emotes

window.addEventListener("keydown", (e) => { if (e.key === "Escape") return send("close"); if (tab !== "assets") return; - if (e.key === "Enter") { if (shown[sel]) play(shown[sel]); return; } + // Consumed: a button left focused by a click would otherwise take the Enter as a press too. + if (e.key === "Enter") { e.preventDefault(); if (shown[sel]) play(shown[sel]); return; } const page = Math.max(1, Math.floor(list.clientHeight / ROW) - 1); if (e.key === "ArrowUp") select(sel - 1); else if (e.key === "ArrowDown") select(sel + 1); diff --git a/resources/hmp-loot/README.md b/resources/hmp-loot/README.md index 600464f..fc97bbb 100644 --- a/resources/hmp-loot/README.md +++ b/resources/hmp-loot/README.md @@ -22,7 +22,7 @@ Copy `examples/config/data/hmp-loot.json` to `/data/hmp-loot.json`: "command": "loot", "enableCommands": true, "adminGroups": [{ "key": "admin", "minimumGrade": 1 }], - "suppressGameLoot": { "chests": true, "moonstones": true, "foragables": true }, + "suppressGameLoot": { "chests": true, "moonstones": true, "foragables": true, "pouches": true }, "lootables": [ { "key": "courtyard-chest", @@ -45,8 +45,9 @@ Copy `examples/config/data/hmp-loot.json` to `/data/hmp-loot.json`: } ``` -- `suppressGameLoot` switches the game's own chests, moonstones and foragables off (`World.suppressLoot`) - while the resource runs, and back on when it stops. Families left out, or `false`, keep the game's loot. +- `suppressGameLoot` switches the game's own chests, moonstones, foragables and coin pouches off + (`World.suppressLoot`) while the resource runs, and back on when it stops. Families left out, or `false`, + keep the game's loot. `pouches` needs a host with pouch suppression (mod commit `388c9e0b` or newer). The Overland spawns go from areas that load after startup, so a client that joins sees none. - `key` names a lootable for good: takes are recorded against it, so renaming one forgets who looted it. - `kind` is `chest`, `moonstone` or `foragable`; `model` (a Blueprint class path) is optional and diff --git a/resources/hmp-loot/server/config.ts b/resources/hmp-loot/server/config.ts index 73833bb..89e8fae 100644 --- a/resources/hmp-loot/server/config.ts +++ b/resources/hmp-loot/server/config.ts @@ -26,7 +26,7 @@ function positiveInt(value: unknown, label: string): number { function families(value: unknown, label: string): HmpLootFamilies { const raw = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; - const out = { chests: false, moonstones: false, foragables: false }; + const out = { chests: false, moonstones: false, foragables: false, pouches: false }; for (const family of Object.keys(out) as Array) { if (raw[family] === undefined) continue; if (typeof raw[family] !== "boolean") throw new TypeError(`${label}.${family} must be a boolean`); @@ -75,7 +75,7 @@ function loadConfig(Hmp: HmpLibServer, options: { env?: NodeJS.Pr command: "loot", enableCommands: true, adminGroups: [{ key: "admin", minimumGrade: 1 }], - suppressGameLoot: { chests: false, moonstones: false, foragables: false }, + suppressGameLoot: { chests: false, moonstones: false, foragables: false, pouches: false }, lootables: [], sweepMs: 1000, }; diff --git a/resources/hmp-loot/server/main.ts b/resources/hmp-loot/server/main.ts index 7102aa0..1580609 100644 --- a/resources/hmp-loot/server/main.ts +++ b/resources/hmp-loot/server/main.ts @@ -27,7 +27,7 @@ const native = typeof Lootables === "undefined" || typeof World === "undefined" const missing = { place: () => { throw new Error("this server build has no Lootables builtin"); }, setItems: () => false, setView: () => false, remove: () => false, - lootSuppressed: { chests: false, moonstones: false, foragables: false }, + lootSuppressed: { chests: false, moonstones: false, foragables: false, pouches: false }, suppressLoot: () => false, }; const service = createLootService({ diff --git a/resources/hmp-loot/server/service.ts b/resources/hmp-loot/server/service.ts index 74d9775..c6bfcba 100644 --- a/resources/hmp-loot/server/service.ts +++ b/resources/hmp-loot/server/service.ts @@ -1,7 +1,7 @@ import type { HmpLootDefinition, HmpLootFamilies, HmpLootItem, HmpLootPlayer, HmpLootRecord, HmpLootState, HmpLootStatus, HmpLootTake } from "../types"; import type { LootDependencies, LootService } from "./internal"; -const NONE: HmpLootFamilies = Object.freeze({ chests: false, moonstones: false, foragables: false }); +const NONE: HmpLootFamilies = Object.freeze({ chests: false, moonstones: false, foragables: false, pouches: false }); const recordId = (key: string, characterId: number | null): string => `${key}\u0000${characterId ?? 0}`; const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); diff --git a/resources/hmp-loot/test/bundle.test.js b/resources/hmp-loot/test/bundle.test.js index b34302d..05878ce 100644 --- a/resources/hmp-loot/test/bundle.test.js +++ b/resources/hmp-loot/test/bundle.test.js @@ -24,7 +24,7 @@ global.Imports = { get: (name) => { if (name === "hmp-core") return core; if (name === "hmp-lib") return { logger: { create: () => logger }, - config: { load: (_path, options) => ({ ...options.defaults, suppressGameLoot: { chests: true, moonstones: true, foragables: true }, lootables }), env: { boolean: (_value, fallback) => fallback } }, + config: { load: (_path, options) => ({ ...options.defaults, suppressGameLoot: { chests: true, moonstones: true, foragables: true, pouches: true }, lootables }), env: { boolean: (_value, fallback) => fallback } }, command: { createRouter: () => ({ register: (name, _options, handler) => commands.set(name, handler), handle: async (p, message, command, args) => { const h = commands.get(command); if (!h) return false; await h({ player: p, message, command, invokedAs: command, args, usage: "usage", reply() { return true; } }); return true; }, @@ -40,7 +40,7 @@ global.Lootables = { setView: (id, p, view) => { views.push({ id, player: p.id, view }); return true; }, remove: () => true, }; -global.World = { lootSuppressed: { chests: false, moonstones: false, foragables: false }, suppressLoot: (families) => { suppressed.push(families); return true; } }; +global.World = { lootSuppressed: { chests: false, moonstones: false, foragables: false, pouches: false }, suppressLoot: (families) => { suppressed.push(families); return true; } }; require(path.resolve(__dirname, "..", "dist", "server.js")); assert.deepStrictEqual([...serverExports.keys()], ["lootables", "status"]); @@ -55,7 +55,7 @@ assert.ok(commands.has("loot")); const status = serverExports.get("status")(); assert.strictEqual(status.state, "ready", status.lastError); assert.strictEqual(status.lootables, 2); - assert.deepStrictEqual(suppressed, [{ chests: true, moonstones: true, foragables: true }]); + assert.deepStrictEqual(suppressed, [{ chests: true, moonstones: true, foragables: true, pouches: true }]); assert.deepStrictEqual(placed.map((p) => [p.kind, p.options.mode, p.options.items.length]), [["chest", "shared", 0], ["foragable", "perPlayer", 1]]); assert.deepStrictEqual(views.at(-1), { id: 2, player: 1, view: null }); views.length = 0; @@ -68,6 +68,6 @@ assert.ok(commands.has("loot")); assert.ok(serverExports.get("lootables").record("courtyard-eggs", 7)); await serverHandlers.get("chatCommand")(player, "/loot list", "loot", ["list"]); serverHandlers.get("resourceStop")("hmp-loot"); - assert.deepStrictEqual(suppressed.at(-1), { chests: false, moonstones: false, foragables: false }); + assert.deepStrictEqual(suppressed.at(-1), { chests: false, moonstones: false, foragables: false, pouches: false }); console.log("hmp-loot bundle contract passed"); })().catch((error) => { console.error(error); process.exit(1); }); diff --git a/resources/hmp-loot/test/loot.test.ts b/resources/hmp-loot/test/loot.test.ts index 977917a..1668e4c 100644 --- a/resources/hmp-loot/test/loot.test.ts +++ b/resources/hmp-loot/test/loot.test.ts @@ -46,13 +46,13 @@ function fakeNative() { remove(id: number) { calls.push(`remove ${id}`); return placed.delete(id); }, }; const world = { - lootSuppressed: { chests: false, moonstones: false, foragables: false }, + lootSuppressed: { chests: false, moonstones: false, foragables: false, pouches: false }, suppressLoot(families: Partial) { suppressedCalls.push(families); return true; }, }; return { lootables, world, placed, views, calls, suppressedCalls }; } -function makeConfig(lootables: HmpLootDefinition[], suppressGameLoot: HmpLootFamilies = { chests: true, moonstones: true, foragables: false }): LootConfig { +function makeConfig(lootables: HmpLootDefinition[], suppressGameLoot: HmpLootFamilies = { chests: true, moonstones: true, foragables: false, pouches: false }): LootConfig { return { command: "loot", enableCommands: true, adminGroups: [], suppressGameLoot, lootables, sweepMs: 1000 }; } @@ -62,13 +62,13 @@ async function run(): Promise { const Hmp = (loaded: Record) => ({ config: { load: (_path: string, options: { defaults: Record }) => ({ ...options.defaults, ...loaded }), env: { boolean: (_v: unknown, fallback: boolean) => fallback } } }); const config = loadConfig(Hmp(defaults) as never, { env: {} as NodeJS.ProcessEnv }); assert.strictEqual(config.command, "loot"); - assert.deepStrictEqual(config.suppressGameLoot, { chests: false, moonstones: false, foragables: false }); + assert.deepStrictEqual(config.suppressGameLoot, { chests: false, moonstones: false, foragables: false, pouches: false }); assert.strictEqual(loadConfig(Hmp(defaults) as never, { env: { HMP_LOOT_COMMAND: "LOOTS" } as NodeJS.ProcessEnv }).command, "loots"); const full = loadConfig(Hmp({ - suppressGameLoot: { chests: true }, + suppressGameLoot: { chests: true, pouches: true }, lootables: [{ key: "courtyard.chest", position: { x: 1, y: 2, z: 3 }, items: [{ itemId: "AshwinderEggs" }], respawnSeconds: 60 }], }) as never, { env: {} as NodeJS.ProcessEnv }); - assert.deepStrictEqual(full.suppressGameLoot, { chests: true, moonstones: false, foragables: false }); + assert.deepStrictEqual(full.suppressGameLoot, { chests: true, moonstones: false, foragables: false, pouches: true }); assert.deepStrictEqual(full.lootables[0], { key: "courtyard.chest", kind: "chest", position: { x: 1, y: 2, z: 3 }, yaw: 0, items: [{ itemId: "AshwinderEggs", count: 1 }], scope: "character", respawnSeconds: 60 }); const bad = (lootables: unknown[], pattern: RegExp) => assert.throws(() => loadConfig(Hmp({ lootables }) as never, { env: {} as NodeJS.ProcessEnv }), pattern); bad([{ key: "a", kind: "barrel", position: { x: 0, y: 0, z: 0 } }], /kind must be/); @@ -113,7 +113,7 @@ async function run(): Promise { assert.strictEqual(service.status().state, "starting"); await service.ready(); assert.strictEqual(service.status().state, "ready"); - assert.deepStrictEqual(native.suppressedCalls, [{ chests: true, moonstones: true, foragables: false }]); + assert.deepStrictEqual(native.suppressedCalls, [{ chests: true, moonstones: true, foragables: false, pouches: false }]); assert.ok(calls.includes("remove eggs/7"), "an expired take is deleted at startup"); assert.ok(rows.has("gone/7"), "a take of a lootable no longer configured is kept"); const ids = Object.fromEntries(service.lootables.list().map((entry) => [entry.key, entry.lootableId])); diff --git a/resources/hmp-loot/types.d.ts b/resources/hmp-loot/types.d.ts index 1debef8..22a80f2 100644 --- a/resources/hmp-loot/types.d.ts +++ b/resources/hmp-loot/types.d.ts @@ -61,6 +61,8 @@ export interface HmpLootFamilies { chests: boolean; moonstones: boolean; foragables: boolean; + /** The game's coin pouches ("Search Pouch"). */ + pouches: boolean; } export interface HmpLootStatus { diff --git a/resources/hmp-progression/README.md b/resources/hmp-progression/README.md index 70e06fe..4d4dc43 100644 --- a/resources/hmp-progression/README.md +++ b/resources/hmp-progression/README.md @@ -9,6 +9,15 @@ single-player simulation, but it cannot award itself durable Foundations XP or i Native level and talent-point reports are accepted only for the exact character, revision, and XP snapshot the server issued. Server gameplay should use this resource's profile for gates. +### Known limitation: kill experience does not persist + +The host pays NPC kill rewards on the killer's client (the NPC `rewardPolicy`: the game's own kill reward +and any flat `experience`). The server is not told the amount, so nothing reaches this resource's ledger: +the experience appears in game, and the next synchronization (reconnect, character switch, or any +progression change) sets it back to the stored total. Field Guide knowledge and kill-challenge progress +from the same kills are unaffected. Award durable kill experience from a server resource with +`progression.add` on `npcDied` until the host reports kill rewards to scripts. + ## Exports `Imports.get("hmp-progression")` exposes: diff --git a/resources/hmp-pvp/README.md b/resources/hmp-pvp/README.md index 21a3f4b..ae9c1b6 100644 --- a/resources/hmp-pvp/README.md +++ b/resources/hmp-pvp/README.md @@ -4,13 +4,18 @@ Gameplay resources register owner-scoped rules instead of replacing one another's global callback. Rules evaluate from highest priority to lowest. `undefined` abstains; the first boolean, number, or -decision object is final. A throwing rule fails closed for that hit. The default is also deny, so -installing Foundations never silently changes a consent-only server into open PvP. +decision object is final. A throwing rule fails closed for that hit. -`hmp-pvp` also owns the server-wide lethality mode. With it off, an otherwise undecided hit is denied. -With it on, an otherwise undecided hit proceeds through native full arbitration, so lethal spell damage -can reach zero health and enter the real death/respawn flow. Higher-priority rules still win; this is why -an active `hmp-duels` pair remains non-lethal even while the rest of the server is lethal. +Rules only ever see hits the host already allowed: its PvP rules and pair overrides decide who is hostile, +and a hit between players who are not is dropped before any policy runs. An undecided hit therefore follows +`defaultDecision`, `"allow"` by default, so the host's own relations stand: the host's default `who: "none"` +keeps a fresh server consent-only, and a team, flag or pair another resource sets up deals damage. Set +`"deny"` to make rules registered here the only way a hit lands. + +`hmp-pvp` also owns the server-wide lethality mode. With it on, everyone is hostile and an undecided hit +proceeds even under `"deny"`, so lethal spell damage can reach zero health and enter the real death/respawn +flow. Higher-priority rules still win; this is why an active `hmp-duels` pair remains non-lethal even while +the rest of the server is lethal. ```ts const PvpPolicy = Imports.get("hmp-pvp"); @@ -34,12 +39,14 @@ Resource-stop cleanup removes every rule owned by that resource. `vitals(playerI native last-reported health view for diagnostics; it remains advisory and potentially stale. Use the victim-enforced `minHp` decision for non-lethal guarantees. -Staff in a configured group may use `/pvp status`, `/pvp lethal on`, `/pvp lethal off`, and `/pvp sync`. +Staff in a configured group may use `/pvp status`, `/pvp lethal on` and `/pvp lethal off`. The compatibility bus event `pvp:lethalMode` accepts a boolean for existing server resources; new resources -should prefer `mode.setLethal()`. Changes emit `hmp:pvp:lethal-mode-changed` for observers. Turning lethal -mode on makes every currently connected player mutually targetable and hostile, and late joiners trigger a -full snapshot fan-out after the configured delay. Re-running `/pvp lethal on` or using `/pvp sync` repairs -presentation after a proxy-streaming miss. +should prefer `mode.setLethal()`. Changes emit `hmp:pvp:lethal-mode-changed` for observers. + +The host resolves relations for every client, late joiners included. Lethal mode is the host rules' +`who: "everyone"`; turning it off sets `who: "none"`, unless something else has already narrowed `who`. +`hmp-pvp` follows `pvpRulesChanged`, so a staff tool or resource that changes `who` directly keeps the mode +in step, and stopping `hmp-pvp` while lethal ends lethal mode. Unforgivable replay suppression remains a native safety boundary. Lethal player kills must use the relayed, server-arbitrated hit path. The current host also does not replay the victim-side green-bolt cosmetic for a diff --git a/resources/hmp-pvp/client/main.ts b/resources/hmp-pvp/client/main.ts index 119097e..8d8701e 100644 --- a/resources/hmp-pvp/client/main.ts +++ b/resources/hmp-pvp/client/main.ts @@ -1,24 +1,7 @@ import type { HmpPvpClient } from "../types"; +// The host resolves who may hurt whom for this client; it only records the server's lethal-mode flag. let lethal = false; -let playerIds: number[] = []; - -function restore(): boolean { - try { - Pvp.setTargetable(null); - Pvp.resetTeam(); - - if (lethal) { - for (const playerId of playerIds) { - Pvp.setTargetable(playerId); - Pvp.setTeam("Enemy", playerId); - } - } - return true; - } catch (_) { - return false; - } -} function apply(raw: unknown): void { let payload: Record = {}; @@ -31,31 +14,17 @@ function apply(raw: unknown): void { } else if (raw && typeof raw === "object") { payload = raw as Record; } - lethal = payload.enabled === true; - playerIds = Array.isArray(payload.playerIds) - ? [...new Set( - payload.playerIds - .map(Number) - .filter((id) => Number.isSafeInteger(id) && id > 0), - )] - : []; - restore(); } const client: HmpPvpClient = Object.freeze({ mode: Object.freeze({ isLethal: () => lethal, - restore, }), }); Exports.register("mode", client.mode); Events.on("hmp-pvp:mode", apply); Events.on("resourceStop", (name?: string) => { - if (!name || name === "hmp-pvp") { - lethal = false; - playerIds = []; - restore(); - } + if (!name || name === "hmp-pvp") lethal = false; }); diff --git a/resources/hmp-pvp/server/main.ts b/resources/hmp-pvp/server/main.ts index c1c0a41..0286d00 100644 --- a/resources/hmp-pvp/server/main.ts +++ b/resources/hmp-pvp/server/main.ts @@ -13,7 +13,6 @@ interface Config { lethalMode: boolean; enableCommands: boolean; command: string; - joinSyncDelayMs: number; adminGroups: Array<{ key: string; minimumGrade?: number }>; } @@ -21,11 +20,10 @@ const configured = Hmp.config.load>( process.env.HMP_PVP_CONFIG || "data/hmp-pvp.json", { defaults: { - defaultDecision: "deny", + defaultDecision: "allow", lethalMode: false, enableCommands: true, command: "pvp", - joinSyncDelayMs: 3000, adminGroups: [{ key: "admin", minimumGrade: 1 }], }, }, @@ -43,34 +41,18 @@ if (!/^[a-z0-9_-]{1,40}$/.test(command)) { throw new TypeError("hmp-pvp command is invalid"); } -const joinSyncDelayMs = Math.max( - 0, - Math.min(30_000, Math.floor(Number(configured.joinSyncDelayMs) || 3000)), -); -const joinTimers = new Map>(); - -function syncMode(enabled: boolean, playerId?: number): void { +// The host owns who may hurt whom and resolves it for every client, late joiners included: lethal mode is its +// rules' `who: "everyone"`. Clients only hear the flag. +function applyMode(enabled: boolean, playerId?: number): void { + if (playerId === undefined) { + const who = Pvp.getRules().who; + if (enabled && who !== "everyone") Pvp.configure({ who: "everyone" }); + else if (!enabled && who === "everyone") Pvp.configure({ who: "none" }); + } const recipients = playerId === undefined ? PlayerManager.getAll() - : (() => { - const player = PlayerManager.getById(playerId); - return player ? [player] : []; - })(); - const all = PlayerManager.getAll(); - - for (const player of recipients) { - player.emit( - "hmp-pvp:mode", - JSON.stringify({ - enabled, - playerIds: enabled - ? all - .filter((other) => other.id !== player.id) - .map((other) => other.id) - : [], - }), - ); - } + : [PlayerManager.getById(playerId)].filter((player): player is HogwartsMpPlayer => Boolean(player)); + for (const player of recipients) player.emit("hmp-pvp:mode", JSON.stringify({ enabled })); } const pvp = createPvpService({ @@ -83,7 +65,7 @@ const pvp = createPvpService({ Pvp.setPolicy(null); }, vitals: (id) => Pvp.getVitals(id), - applyMode: syncMode, + applyMode, modeChanged: (enabled, context) => { Events.emit("hmp:pvp:lethal-mode-changed", { enabled, ...context }); }, @@ -95,29 +77,18 @@ Exports.register("mode", pvp.mode); Exports.register("vitals", pvp.vitals); Exports.register("status", pvp.status); -function delayedSync(player: HogwartsMpPlayer): void { - if (!pvp.mode.isLethal()) return; - - const playerId = Number(player.id); - const existing = joinTimers.get(playerId); - if (existing) clearTimeout(existing); - - const timer = setTimeout(() => { - joinTimers.delete(playerId); - // Re-send complete snapshots to both the joiner and existing players. A one-sided - // sync would let the joiner target everyone without making the joiner targetable. - pvp.mode.sync(); - }, joinSyncDelayMs); - timer.unref?.(); - joinTimers.set(playerId, timer); -} - -Events.on("playerConnect", delayedSync); Events.on("hmp:session:ready", (session: unknown) => { const player = session && typeof session === "object" && "player" in session ? (session.player as HogwartsMpPlayer) : null; - if (player) delayedSync(player); + if (player && pvp.mode.isLethal()) pvp.mode.sync(player); +}); +// Staff tools and other resources may change the rules directly; the mode follows them. +Events.on("pvpRulesChanged", (rules: HogwartsMpPvpRules) => { + pvp.mode.setLethal(rules.who === "everyone", { + resource: "pvpRulesChanged", + reason: "host PvP rules changed", + }); }); Events.on("pvp:lethalMode", (enabled: unknown) => { pvp.mode.setLethal(enabled === true, { @@ -127,8 +98,8 @@ Events.on("pvp:lethalMode", (enabled: unknown) => { }); Events.on("resourceStop", (name?: string) => { if (!name || name === "hmp-pvp") { - for (const timer of joinTimers.values()) clearTimeout(timer); - joinTimers.clear(); + // Without the policy broker, open lethal rules would let every hit through. + if (pvp.mode.isLethal()) applyMode(false); pvp.stop(); } else { pvp.cleanup(name); @@ -170,7 +141,7 @@ if (configured.enableCommands !== false) { if (action === "status") { const status = pvp.status(); reply( - `${status.lethalMode ? "LETHAL" : "consent-only"}; ` + `${status.lethalMode ? "LETHAL" : "lethal off"}; default ${status.defaultDecision}; ` + `${status.rules} rule(s), ${status.allowed} allowed / ` + `${status.denied} denied / ${status.errors} errors`, ); @@ -182,12 +153,6 @@ if (configured.enableCommands !== false) { return; } - if (action === "sync") { - pvp.mode.sync(); - reply("PvP presentation re-sent to every connected player."); - return; - } - if (action === "lethal") { const enabled = String(args[1] || "on").toLowerCase() !== "off"; const changed = pvp.mode.setLethal(enabled, { @@ -195,21 +160,19 @@ if (configured.enableCommands !== false) { actor: player, reason: "staff command", }); + if (!changed) { + reply(`Lethal mode is already ${enabled ? "on" : "off"}.`); + return; + } announce( `open-world LETHAL PvP ${enabled ? "ON — everyone is targetable and lethal spells can cause real death" : "off — consent-only rules restored"}`, ); - if (!changed) { - pvp.mode.sync(); - reply( - "Mode was already set; presentation was re-sent to streamed proxies.", - ); - } return; } - reply(`Usage: /${command} `); + reply(`Usage: /${command} `); })().catch((error) => { reply(error instanceof Error ? error.message : String(error)); }); diff --git a/resources/hmp-pvp/test/bundle.test.js b/resources/hmp-pvp/test/bundle.test.js index 642d802..36cf7c7 100644 --- a/resources/hmp-pvp/test/bundle.test.js +++ b/resources/hmp-pvp/test/bundle.test.js @@ -10,35 +10,47 @@ global.Imports = { get: (name) => name === "hmp-core" ? { groups: { has: async () => true } } : { logger: { create: () => ({ info() {}, warn() {}, error() {} }) }, config: { load: (_p, o) => o.defaults } } }; global.PlayerManager = { getAll: () => [player], getById: (id) => id === player.id ? player : null }; -global.Pvp = { setPolicy: (fn) => { nativePolicy = fn || null; return true; }, getVitals: () => null }; +let rules = { who: "none" }; +const configured = []; +global.Pvp = { + setPolicy: (fn) => { nativePolicy = fn || null; return true; }, + getVitals: () => null, + getRules: () => ({ ...rules }), + configure: (patch) => { configured.push(patch); rules = { ...rules, ...patch }; return { ...rules }; }, +}; global.Events = { on: (name, handler) => handlers.set(name, handler), emit() {} }; require(path.resolve(__dirname, "..", "dist", "server.js")); assert.deepStrictEqual([...exportsMap.keys()], ["policy", "mode", "vitals", "status"]); assert.strictEqual(typeof nativePolicy, "function"); +assert.deepStrictEqual(configured, []); assert.ok(handlers.has("resourceStop")); assert.ok(handlers.has("chatCommand")); +assert.ok(handlers.has("pvpRulesChanged")); handlers.get("chatCommand")(player, "", "pvp", ["lethal", "on"]); setImmediate(() => { assert.strictEqual(exportsMap.get("mode").isLethal(), true); + assert.deepStrictEqual(configured, [{ who: "everyone" }]); assert.ok(messages.some((message) => message.includes("LETHAL PvP ON"))); + assert.ok(emitted.some((entry) => entry.name === "hmp-pvp:mode" && JSON.parse(entry.payload).enabled === true)); + // Someone else narrowing the rules ends lethal mode without hmp-pvp overwriting their choice. + rules = { who: "teams" }; + handlers.get("pvpRulesChanged")({ ...rules }); + assert.strictEqual(exportsMap.get("mode").isLethal(), false); + assert.deepStrictEqual(configured, [{ who: "everyone" }]); + exportsMap.get("mode").setLethal(true, { resource: "test" }); handlers.get("resourceStop")("hmp-pvp"); + assert.deepStrictEqual(configured.at(-1), { who: "none" }); assert.strictEqual(nativePolicy, null); - const clientExports = new Map(), clientHandlers = new Map(), clientCalls = []; + const clientExports = new Map(), clientHandlers = new Map(); global.Exports = { register: (name, value) => clientExports.set(name, value) }; global.Events = { on: (name, handler) => clientHandlers.set(name, handler) }; - global.Pvp = { - setTargetable: (id) => clientCalls.push(["targetable", id]), - resetTeam: () => clientCalls.push(["resetTeam"]), - setTeam: (team, id) => clientCalls.push(["team", team, id]), - }; + delete global.Pvp; require(path.resolve(__dirname, "..", "dist", "client.js")); assert.deepStrictEqual([...clientExports.keys()], ["mode"]); - clientHandlers.get("hmp-pvp:mode")(JSON.stringify({ enabled: true, playerIds: [2, 3, 3, -1] })); + clientHandlers.get("hmp-pvp:mode")(JSON.stringify({ enabled: true })); assert.strictEqual(clientExports.get("mode").isLethal(), true); - assert.ok(clientCalls.some((call) => call[0] === "team" && call[2] === 2)); - assert.ok(clientCalls.some((call) => call[0] === "team" && call[2] === 3)); clientHandlers.get("resourceStop")("hmp-pvp"); assert.strictEqual(clientExports.get("mode").isLethal(), false); console.log("hmp-pvp bundle contract passed"); diff --git a/resources/hmp-pvp/types.d.ts b/resources/hmp-pvp/types.d.ts index f17bb3b..d3ac050 100644 --- a/resources/hmp-pvp/types.d.ts +++ b/resources/hmp-pvp/types.d.ts @@ -5,6 +5,10 @@ export interface HmpPvpHit { spell: string; damage: number; duration: number; + /** The floor the victim's client applies: the rules' kneelHp under a kneel defeat, else 0. */ + minHp?: number; + dotSeconds?: number; + defeat?: "death" | "kneel"; victimHp?: number; victimMax?: number; } @@ -60,7 +64,5 @@ export interface HmpPvp

{ export interface HmpPvpClient { mode: { isLethal(): boolean; - /** Reapply the current server-authored targetability/team snapshot after temporary duel state. */ - restore(): boolean; }; } diff --git a/resources/hmp-world/README.md b/resources/hmp-world/README.md index 5272efd..bc91eb5 100644 --- a/resources/hmp-world/README.md +++ b/resources/hmp-world/README.md @@ -2,8 +2,10 @@ `hmp-world` owns the server-wide baseline for Hogwarts Legacy's environment and native world behavior. It applies starting weather, clock, date and season on resource startup, then sends the -configured boundary, population and encounter policy to every client. Policies are re-sent when the -client resource becomes ready, the world becomes ready, and a character finishes loading. +configured boundary and population policy to every client. Policies are re-sent when the client +resource becomes ready, the world becomes ready, and a character finishes loading. Enemy encounters +are the server's: `encounters` goes to `World.setEncounterPolicy`, which the host applies to every +client, including late joiners. The resource reads and mutates the Framework's replicated environment through the flat `World.*` properties and setters. Its own `world.environment` export remains the stable Foundations API used @@ -13,7 +15,7 @@ by other resources. Copy `examples/config/data/hmp-world.json` to `/data/hmp-world.json`. The bundled defaults remove native mount boundaries—including Hogsmeade's no-fly zone—and suppress ambient population and -native enemy encounters: +the Overland's enemy encounters: ```json { @@ -26,11 +28,15 @@ native enemy encounters: "policy": { "removeBoundaryVolumes": true, "ambientPopulation": false, - "nativeEncounters": false + "encounters": "suppressed" } } ``` +`encounters` decides who spawns the Overland's enemy camps and roaming world events: `"suppressed"` +(the default) spawns none, and `"server"` spawns them on the server as NPCs every player shares. Mission +enemies always come from the game. + `time.scale` is in-game minutes per real second: `1` gives a 24-minute in-game day and `0` freezes the clock. `date.year: 0` preserves the game's native year. Seasons accept `spring`, `summer`, `autumn`, `winter`, or `0` through `3`. Weather names are passed to Hogwarts Legacy as configured. diff --git a/resources/hmp-world/client/main.ts b/resources/hmp-world/client/main.ts index 663d98b..d075b08 100644 --- a/resources/hmp-world/client/main.ts +++ b/resources/hmp-world/client/main.ts @@ -4,7 +4,6 @@ import type { HmpLibClient } from "../../hmp-lib/types"; declare const World: { setBoundaryPolicy(remove: boolean): number; setAmbientPopulation(enabled: boolean): boolean; - setEncounterPolicy(suppress: boolean): number; }; const { createWorldClient } = serviceModule; diff --git a/resources/hmp-world/client/service.ts b/resources/hmp-world/client/service.ts index 2733c07..f8f35e0 100644 --- a/resources/hmp-world/client/service.ts +++ b/resources/hmp-world/client/service.ts @@ -1,9 +1,8 @@ -import type { HmpWorldPolicy } from "../types"; +import type { HmpWorldClientPolicy } from "../types"; interface NativeWorld { setBoundaryPolicy(remove: boolean): number; setAmbientPopulation(enabled: boolean): boolean; - setEncounterPolicy(suppress: boolean): number; } interface ClientOptions { @@ -11,7 +10,7 @@ interface ClientOptions { emitReady(): void; } -function parsePolicy(raw: unknown): HmpWorldPolicy | null { +function parsePolicy(raw: unknown): HmpWorldClientPolicy | null { let value: unknown = raw; if (typeof value === "string") { try { value = JSON.parse(value); } @@ -19,21 +18,18 @@ function parsePolicy(raw: unknown): HmpWorldPolicy | null { } if (!value || typeof value !== "object" || Array.isArray(value)) return null; const policy = value as Record; - if (policy.removeBoundaryVolumes === undefined || policy.ambientPopulation === undefined || policy.nativeEncounters === undefined) return null; - if (typeof policy.removeBoundaryVolumes !== "boolean" || typeof policy.ambientPopulation !== "boolean" || typeof policy.nativeEncounters !== "boolean") return null; + if (typeof policy.removeBoundaryVolumes !== "boolean" || typeof policy.ambientPopulation !== "boolean") return null; return { removeBoundaryVolumes: policy.removeBoundaryVolumes, ambientPopulation: policy.ambientPopulation, - nativeEncounters: policy.nativeEncounters, }; } function createWorldClient(options: ClientOptions) { const { native } = options; let state: "ready" | "stopped" = "ready"; - let currentPolicy: HmpWorldPolicy | null = null; + let currentPolicy: HmpWorldClientPolicy | null = null; let boundaryVolumesChanged = 0; - let encounterVolumesChanged = 0; function apply(raw: unknown): boolean { if (state === "stopped") return false; @@ -41,7 +37,6 @@ function createWorldClient(options: ClientOptions) { if (!next) return false; boundaryVolumesChanged = native.setBoundaryPolicy(next.removeBoundaryVolumes); native.setAmbientPopulation(next.ambientPopulation); - encounterVolumesChanged = native.setEncounterPolicy(!next.nativeEncounters); currentPolicy = next; return true; } @@ -60,7 +55,7 @@ function createWorldClient(options: ClientOptions) { apply, stop, policy: Object.freeze({ current: () => currentPolicy ? { ...currentPolicy } : null, restore }), - status: () => ({ state, policy: currentPolicy ? { ...currentPolicy } : null, boundaryVolumesChanged, encounterVolumesChanged }), + status: () => ({ state, policy: currentPolicy ? { ...currentPolicy } : null, boundaryVolumesChanged }), }); options.emitReady(); return client; diff --git a/resources/hmp-world/server/config.ts b/resources/hmp-world/server/config.ts index 5a9ca36..762cf66 100644 --- a/resources/hmp-world/server/config.ts +++ b/resources/hmp-world/server/config.ts @@ -1,5 +1,5 @@ import type { HmpLibServer } from "../../hmp-lib/types"; -import type { HmpWorldPlayer, HmpWorldSeason } from "../types"; +import type { HmpWorldEncounters, HmpWorldPlayer, HmpWorldSeason } from "../types"; import { SEASONS, type WorldConfig } from "./internal"; function finiteInteger(value: unknown, label: string, minimum: number, maximum: number): number { @@ -23,6 +23,12 @@ function boolean(value: unknown, label: string): boolean { return value; } +function encounters(value: unknown, label: string): HmpWorldEncounters { + if (value === undefined) return "suppressed"; + if (value !== "suppressed" && value !== "server") throw new TypeError(`${label} must be "suppressed" or "server"`); + return value; +} + function weather(value: unknown): string { const result = String(value || "").trim(); if (!result || result.length > 128) throw new TypeError("hmp-world environment.weather must be a non-empty string up to 128 characters"); @@ -67,7 +73,7 @@ function normalizeConfig(raw: unknown): WorldConfig { policy: { removeBoundaryVolumes: boolean(policy.removeBoundaryVolumes, "hmp-world policy.removeBoundaryVolumes"), ambientPopulation: boolean(policy.ambientPopulation, "hmp-world policy.ambientPopulation"), - nativeEncounters: boolean(policy.nativeEncounters, "hmp-world policy.nativeEncounters"), + encounters: encounters(policy.encounters, "hmp-world policy.encounters"), }, }; } @@ -84,7 +90,7 @@ function loadConfig(Hmp: HmpLibServer, options: { env?: NodeJS.P policy: { removeBoundaryVolumes: true, ambientPopulation: false, - nativeEncounters: false, + encounters: "suppressed", }, }; const loaded = Hmp.config.load>(env.HMP_WORLD_CONFIG || "data/hmp-world.json", { diff --git a/resources/hmp-world/server/internal.ts b/resources/hmp-world/server/internal.ts index 945e890..38523d5 100644 --- a/resources/hmp-world/server/internal.ts +++ b/resources/hmp-world/server/internal.ts @@ -27,6 +27,7 @@ export interface NativeWorld { setDate(day: number, month: number, year?: number): boolean; setSeason(season: number): boolean; setTimeScale(minutesPerSecond: number): boolean; + setEncounterPolicy(policy: "suppressed" | "server"): boolean; } export interface WorldEvents

{ diff --git a/resources/hmp-world/server/main.ts b/resources/hmp-world/server/main.ts index 8adcfe9..1d1ac22 100644 --- a/resources/hmp-world/server/main.ts +++ b/resources/hmp-world/server/main.ts @@ -33,4 +33,4 @@ Events.on("playerDisconnect", (player: HmpWorldPlayer) => world.disconnect(playe Events.on("resourceStop", (name?: string) => { if (!name || name === "hmp-world") world.stop(); }); const policy = world.policy.current(); -logger.info(`World ready: ${config.environment.weather}, ${String(config.environment.time.hour).padStart(2, "0")}:${String(config.environment.time.minute).padStart(2, "0")}, boundaries ${policy.removeBoundaryVolumes ? "removed" : "preserved"}`); +logger.info(`World ready: ${config.environment.weather}, ${String(config.environment.time.hour).padStart(2, "0")}:${String(config.environment.time.minute).padStart(2, "0")}, boundaries ${policy.removeBoundaryVolumes ? "removed" : "preserved"}, encounters ${policy.encounters}`); diff --git a/resources/hmp-world/server/service.ts b/resources/hmp-world/server/service.ts index 5767e2d..49793ce 100644 --- a/resources/hmp-world/server/service.ts +++ b/resources/hmp-world/server/service.ts @@ -33,9 +33,12 @@ function numericSeason(value: HmpWorldSeason): 0 | 1 | 2 | 3 { function normalizePolicyPatch(current: HmpWorldPolicy, patch: Partial): HmpWorldPolicy { if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("world policy patch must be an object"); - for (const key of ["removeBoundaryVolumes", "ambientPopulation", "nativeEncounters"] as const) { + for (const key of ["removeBoundaryVolumes", "ambientPopulation"] as const) { if (patch[key] !== undefined && typeof patch[key] !== "boolean") throw new TypeError(`world policy ${key} must be a boolean`); } + if (patch.encounters !== undefined && patch.encounters !== "suppressed" && patch.encounters !== "server") { + throw new TypeError('world policy encounters must be "suppressed" or "server"'); + } return { ...current, ...patch }; } @@ -80,8 +83,21 @@ function createWorldService

(options: WorldServiceOptio return environmentState(); } + // The host applies encounters for every client, including late joiners, so they are not part of the client sync. + function applyEncounterPolicy(value: HmpWorldPolicy): void { + if (!native.setEncounterPolicy(value.encounters)) throw new Error(`Framework rejected encounter policy '${value.encounters}'`); + } + + function commitPolicy(next: HmpWorldPolicy): Readonly { + if (next.encounters !== currentPolicy.encounters) applyEncounterPolicy(next); + currentPolicy = Object.freeze(next); + sync(); + return currentPolicy; + } + function policyPayload(): string { - return JSON.stringify({ contract: "hmp.world.policy/v1", ...currentPolicy }); + const { removeBoundaryVolumes, ambientPopulation } = currentPolicy; + return JSON.stringify({ contract: "hmp.world.policy/v2", removeBoundaryVolumes, ambientPopulation }); } function sync(player?: P): number { @@ -97,16 +113,12 @@ function createWorldService

(options: WorldServiceOptio function setPolicy(patch: Partial): Readonly { ensureReady(); - currentPolicy = Object.freeze(normalizePolicyPatch(currentPolicy, patch)); - sync(); - return currentPolicy; + return commitPolicy(normalizePolicyPatch(currentPolicy, patch)); } function resetPolicy(): Readonly { ensureReady(); - currentPolicy = Object.freeze(clonePolicy(baselinePolicy)); - sync(); - return currentPolicy; + return commitPolicy(clonePolicy(baselinePolicy)); } const environment = Object.freeze({ @@ -128,6 +140,7 @@ function createWorldService

(options: WorldServiceOptio }); applyEnvironment(); + applyEncounterPolicy(currentPolicy); return Object.freeze({ environment, diff --git a/resources/hmp-world/test/bundle.test.js b/resources/hmp-world/test/bundle.test.js index 9a59586..1e5de5f 100644 --- a/resources/hmp-world/test/bundle.test.js +++ b/resources/hmp-world/test/bundle.test.js @@ -7,6 +7,7 @@ const serverHandlers = new Map(); const playerEvents = []; const player = { id: 1, nickname: "Smoke", connected: true, emit: (name, payload) => playerEvents.push({ name, payload }) }; let environment = { weather: "Default_PHY", hour: 12, minute: 0, second: 0, day: 1, month: 1, year: 0, season: 0, timeScale: 1, revision: 0 }; +const encounterPolicies = []; global.Exports = { register: (name, value) => serverExports.set(name, value) }; global.Imports = { get: (name) => { if (name !== "hmp-lib") throw new Error(`Unexpected import ${name}`); @@ -37,14 +38,17 @@ global.World = { setDate: (day, month, year = 0) => { Object.assign(environment, { day, month, year }); environment.revision++; return true; }, setSeason: (season) => { environment.season = season; environment.revision++; return true; }, setTimeScale: (timeScale) => { environment.timeScale = timeScale; environment.revision++; return true; }, + setEncounterPolicy: (policy) => { encounterPolicies.push(policy); return true; }, }; require(path.resolve(__dirname, "..", "dist", "server.js")); assert.deepStrictEqual([...serverExports.keys()], ["environment", "policy", "status"]); +assert.deepStrictEqual(encounterPolicies, ["suppressed"]); assert.ok(serverHandlers.has("client:hmp-world:ready")); assert.ok(serverHandlers.has("worldReady")); assert.strictEqual(serverExports.get("policy").current().removeBoundaryVolumes, true); serverHandlers.get("client:hmp-world:ready")(player); assert.strictEqual(playerEvents[0].name, "hmp-world:policy"); +assert.ok(!("encounters" in JSON.parse(playerEvents[0].payload))); const clientExports = new Map(); const clientHandlers = new Map(); @@ -59,14 +63,13 @@ global.Events = { global.World = { setBoundaryPolicy: (value) => { worldCalls.push(["boundaries", value]); return 1; }, setAmbientPopulation: (value) => { worldCalls.push(["population", value]); return true; }, - setEncounterPolicy: (value) => { worldCalls.push(["encounters", value]); return 1; }, }; require(path.resolve(__dirname, "..", "dist", "client.js")); assert.deepStrictEqual([...clientExports.keys()], ["policy", "status"]); assert.deepStrictEqual(clientEmits.map((entry) => entry.name), ["hmp-world:ready"]); assert.ok(clientHandlers.has("hmp-world:policy")); -clientHandlers.get("hmp-world:policy")({ removeBoundaryVolumes: true, ambientPopulation: false, nativeEncounters: false }); -assert.deepStrictEqual(worldCalls, [["boundaries", true], ["population", false], ["encounters", true]]); +clientHandlers.get("hmp-world:policy")({ removeBoundaryVolumes: true, ambientPopulation: false }); +assert.deepStrictEqual(worldCalls, [["boundaries", true], ["population", false]]); assert.strictEqual(clientExports.get("policy").restore(), true); clientHandlers.get("resourceStop")("hmp-world"); console.log("hmp-world bundle contract passed"); diff --git a/resources/hmp-world/test/world.test.ts b/resources/hmp-world/test/world.test.ts index 625a11e..450d93c 100644 --- a/resources/hmp-world/test/world.test.ts +++ b/resources/hmp-world/test/world.test.ts @@ -14,9 +14,12 @@ const config = normalizeConfig({ date: { day: 1, month: 9, year: 0 }, season: "autumn", }, - policy: { removeBoundaryVolumes: true, ambientPopulation: false, nativeEncounters: false }, + policy: { removeBoundaryVolumes: true, ambientPopulation: false }, }); assert.strictEqual(config.environment.season, "autumn"); +assert.strictEqual(config.policy.encounters, "suppressed"); +assert.strictEqual(normalizeConfig({ ...config, policy: { ...config.policy, encounters: "server" } }).policy.encounters, "server"); +assert.throws(() => normalizeConfig({ ...config, policy: { ...config.policy, encounters: "native" } }), /"suppressed" or "server"/); assert.throws(() => normalizeConfig({ ...config, policy: { ...config.policy, removeBoundaryVolumes: "yes" } }), /boolean/); assert.throws(() => normalizeConfig({ ...config, environment: { ...config.environment, time: { ...config.environment.time, hour: 24 } } }), /0 to 23/); @@ -41,26 +44,37 @@ const native = { setDate(day: number, month: number, year = 0) { nativeCalls.push(["date", day, month, year]); Object.assign(environmentState, { day, month, year }); environmentState.revision++; return true; }, setSeason(season: number) { nativeCalls.push(["season", season]); environmentState.season = season as 0 | 1 | 2 | 3; environmentState.revision++; return true; }, setTimeScale(scale: number) { nativeCalls.push(["scale", scale]); environmentState.timeScale = scale; environmentState.revision++; return true; }, + setEncounterPolicy(policy: string) { nativeCalls.push(["encounters", policy]); return true; }, }; const players = [{ id: 4, nickname: "Tester", connected: true, emitted: [] as unknown[], emit(name: string, payload?: unknown) { this.emitted.push({ name, payload }); } }]; const broadcasts: Array<{ name: string; payload: unknown }> = []; const service = createWorldService({ config, native, players: () => players, events: { emitAllClients: (name, payload) => broadcasts.push({ name, payload }) } }); -assert.deepStrictEqual(nativeCalls.map(([name]) => name), ["scale", "weather", "date", "season", "time", "scale"]); +assert.deepStrictEqual(nativeCalls.map(([name]) => name), ["scale", "weather", "date", "season", "time", "scale", "encounters"]); +assert.deepStrictEqual(nativeCalls.at(-1), ["encounters", "suppressed"]); assert.strictEqual(service.environment.state()?.weather, "Clear"); assert.strictEqual(service.environment.state()?.season, 2); assert.strictEqual(service.policy.sync(players[0]), 1); assert.strictEqual(players[0].emitted.length, 1); -service.policy.set({ nativeEncounters: true }); -assert.strictEqual(service.policy.current().nativeEncounters, true); +// Clients apply only what they own; encounters stay on the server. +assert.deepStrictEqual(JSON.parse(String((players[0].emitted[0] as { payload: unknown }).payload)), { contract: "hmp.world.policy/v2", removeBoundaryVolumes: true, ambientPopulation: false }); +service.policy.set({ encounters: "server" }); +assert.strictEqual(service.policy.current().encounters, "server"); +assert.deepStrictEqual(nativeCalls.at(-1), ["encounters", "server"]); assert.strictEqual(broadcasts.length, 1); +assert.throws(() => service.policy.set({ encounters: "native" as never }), /"suppressed" or "server"/); +const encounterCalls = () => nativeCalls.filter(([name]) => name === "encounters").length; +const encountersBefore = encounterCalls(); +service.policy.set({ removeBoundaryVolumes: false }); +assert.strictEqual(encounterCalls(), encountersBefore); service.policy.reset(); -assert.strictEqual(service.policy.current().nativeEncounters, false); -assert.strictEqual(broadcasts.length, 2); +assert.strictEqual(service.policy.current().encounters, "suppressed"); +assert.deepStrictEqual(nativeCalls.at(-1), ["encounters", "suppressed"]); +assert.strictEqual(broadcasts.length, 3); service.environment.setSeason("winter"); assert.strictEqual(environmentState.season, 3); assert.strictEqual(parsePolicy("bad"), null); -assert.deepStrictEqual(parsePolicy(JSON.stringify(config.policy)), config.policy); +assert.deepStrictEqual(parsePolicy(JSON.stringify(config.policy)), { removeBoundaryVolumes: true, ambientPopulation: false }); const clientCalls: Array<[string, unknown]> = []; let ready = 0; const client = createWorldClient({ @@ -68,15 +82,15 @@ const client = createWorldClient({ native: { setBoundaryPolicy: (value) => { clientCalls.push(["boundaries", value]); return 12; }, setAmbientPopulation: (value) => { clientCalls.push(["population", value]); return true; }, - setEncounterPolicy: (value) => { clientCalls.push(["encounters", value]); return 8; }, }, }); assert.strictEqual(ready, 1); assert.strictEqual(client.apply(config.policy), true); -assert.deepStrictEqual(clientCalls, [["boundaries", true], ["population", false], ["encounters", true]]); +assert.deepStrictEqual(clientCalls, [["boundaries", true], ["population", false]]); assert.strictEqual(client.status().boundaryVolumesChanged, 12); +assert.deepStrictEqual(client.policy.current(), { removeBoundaryVolumes: true, ambientPopulation: false }); assert.strictEqual(client.policy.restore(), true); -assert.strictEqual(clientCalls.length, 6); +assert.strictEqual(clientCalls.length, 4); client.stop(); assert.strictEqual(client.policy.restore(), false); assert.strictEqual(client.apply(config.policy), false); diff --git a/resources/hmp-world/types.d.ts b/resources/hmp-world/types.d.ts index 35a48c9..c1fd9f8 100644 --- a/resources/hmp-world/types.d.ts +++ b/resources/hmp-world/types.d.ts @@ -21,15 +21,24 @@ export interface HmpWorldEnvironmentConfig { season: HmpWorldSeason; } +/** + * Who spawns the Overland's enemy camps and roaming world events: nobody (`suppressed`), or the server as NPCs + * every player shares (`server`). Mission enemies always come from the game. + */ +export type HmpWorldEncounters = "suppressed" | "server"; + export interface HmpWorldPolicy { /** Destroy native mount boundary volumes, including Hogsmeade's no-fly zone. */ removeBoundaryVolumes: boolean; /** Restore Hogwarts Legacy's ambient student and seat-filler population. */ ambientPopulation: boolean; - /** Allow native DynamicObjectVolume enemy encounters to spawn. */ - nativeEncounters: boolean; + /** Applied by the server for every client (`World.setEncounterPolicy`); clients are not sent it. */ + encounters: HmpWorldEncounters; } +/** The part of the policy each client applies itself. */ +export type HmpWorldClientPolicy = Pick; + export interface HmpWorldEnvironmentState { weather: string; hour: number; @@ -84,16 +93,15 @@ export interface HmpWorld

{ } export interface HmpWorldClientPolicyApi { - current(): Readonly | null; + current(): Readonly | null; /** Reapply the most recent server-authored policy after a temporary client-side override. */ restore(): boolean; } export interface HmpWorldClientStatus { state: "ready" | "stopped"; - policy: Readonly | null; + policy: Readonly | null; boundaryVolumesChanged: number; - encounterVolumesChanged: number; } export interface HmpWorldClient { diff --git a/types/hogwartsmp.d.ts b/types/hogwartsmp.d.ts index b9dce6f..fe0775a 100644 --- a/types/hogwartsmp.d.ts +++ b/types/hogwartsmp.d.ts @@ -259,6 +259,7 @@ declare global { release?(owner: string): boolean; holds?(): Array<{ owner: string }>; location?(): HogwartsMpPlayerLocation | null; + setHealthRecovery?(on: boolean): void; } interface HogwartsMpNpc { @@ -301,6 +302,8 @@ declare global { on(eventName: "playerTeleportComplete", listener: (player: HogwartsMpPlayer, requestId: number, status: number, completion: HogwartsMpTeleportCompletion) => unknown): void; on(eventName: "playerLocationChanged", listener: (player: HogwartsMpPlayer, current: HogwartsMpPlayerLocation | null, previous: HogwartsMpPlayerLocation | null) => unknown): void; on(eventName: "resourceStop", listener: (name?: string) => unknown): void; + on(eventName: "pvpDefeat", listener: (victim: HogwartsMpPlayer, killer: HogwartsMpPlayer, kind: "death" | "kneel") => unknown): void; + on(eventName: "pvpRulesChanged", listener: (rules: HogwartsMpPvpRules) => unknown): void; on(eventName: string, listener: (...args: TArgs) => unknown): void; onClient(eventName: string, listener: (player: HogwartsMpPlayer, ...args: TArgs) => unknown): void; emit(eventName: string, ...args: unknown[]): unknown; @@ -367,23 +370,41 @@ declare global { spell: string; damage: number; duration: number; + minHp?: number; + dotSeconds?: number; + defeat?: "death" | "kneel"; victimHp?: number; victimMax?: number; } type HogwartsMpPvpDecision = void | boolean | number | { allow?: boolean; damage?: number; duration?: number; minHp?: number }; + interface HogwartsMpPvpRules { + enabled: boolean; + who: "none" | "flagged" | "teams" | "everyone"; + aim: "lock" | "skillshot"; + defeat: "death" | "kneel"; + friendlyFire: boolean; + curses: "on" | "off"; + kneelHp: number; + flagCombatLockSeconds: number; + spawnProtectionSeconds: number; + damageScale: number; + } + interface HogwartsMpPvpRelation { + hostile: boolean; + aim: "lock" | "skillshot"; + defeat: "death" | "kneel"; + reason: string; + } + /** Server and client builtins share the global; the server owns who may hurt whom. */ const Pvp: { + configure(patch: Partial): HogwartsMpPvpRules; + getRules(): HogwartsMpPvpRules; + setPair(a: HogwartsMpPlayer | number, b: HogwartsMpPlayer | number, options?: { hostile?: boolean; aim?: "lock" | "skillshot"; defeat?: "death" | "kneel" } | null): boolean; + relation(a: HogwartsMpPlayer | number, b?: HogwartsMpPlayer | number): HogwartsMpPvpRelation | null; setPolicy(fn?: ((hit: HogwartsMpPvpHit) => HogwartsMpPvpDecision) | null): boolean; - setDamage(amount: number): number; setSpellDamage(spell: string, damage?: number | null): boolean; - setDuration(seconds: number): number; getVitals(playerId: number): { hp: number; max: number; level: number; ageMs: number } | null; - setTeam(team?: string, playerId?: number): string; - resetTeam(playerId?: number): string; - setTargetable(playerId?: number | null, on?: boolean): string; - kneel(): string; - startDuelContext(arenaControls?: boolean): string; - endDuelContext(): string; - setRegen(enabled: boolean, seconds?: number): string; + /** Client: the opponent health meter. */ showMeter(playerId?: number | "auto" | null): string; }; const InventoryCatalog: HogwartsMpInventoryCatalog; @@ -435,6 +456,16 @@ declare global { getAll(): HogwartsMpPlayer[]; getById(id: number): HogwartsMpPlayer | null; }; + interface HogwartsMpChatOptions { + author?: string; + /** Packed 0xRRGGBBAA. */ + color?: number; + } + /** Server only. */ + const Chat: { + sendToAll(text: string, options?: HogwartsMpChatOptions): void; + sendToPlayer(player: HogwartsMpPlayer, text: string, options?: HogwartsMpChatOptions): void; + }; const Web: { createView(url: string, options?: { width?: number; height?: number; x?: number; y?: number; zIndex?: number; visible?: boolean; focus?: boolean }): number; destroyView(view: number): boolean; @@ -476,6 +507,8 @@ declare global { getPosition(): HogwartsMpVector3 | null; setPosition(x: number, y: number, z: number): boolean; setCollision(enabled: boolean): boolean; + /** Shows or hides the local body, wand and held prop on this screen only. Holds through respawns and travel. */ + setVisibility(visible: boolean): boolean; clearVelocity(): boolean; getRotation(): { pitch: number; yaw: number; roll: number } | null; getControlRotation(): { pitch: number; yaw: number; roll: number } | null;