Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
type="font/woff2"
crossorigin
/>
<title>hyprverse — step into the metaverse</title>
<title>hyprverse</title>
</head>
<body>
<div id="root"></div>
Expand Down
7 changes: 0 additions & 7 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,6 @@ html, body, #root {
.console-submit:disabled { opacity: 0.6; cursor: default; }
.console-submit-arrow { transition: transform 0.18s ease; }
.console-submit:hover:not(:disabled) .console-submit-arrow { transform: translateX(3px); }
.console-foot {
margin: 14px 0 0;
font-size: 12px;
font-weight: 600;
text-align: center;
color: var(--hv-muted);
}

/* Shared keyboard-focus ring for every interactive landing control. */
.landing button:focus-visible,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/game/boardTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe("boardTableView — endings", () => {
seats: [null, { ...bob, accepted: true }],
state: { board: board9(), turn: 1, result: { status: "in_progress" } },
});
expect(boardTableView(forfeit, "b").status).toBe("Opponent left — you win!");
expect(boardTableView(forfeit, "b").status).toBe("Opponent left. You win!");
// The forfeiter's seat is emptied, so they (and any spectator) see the same.
expect(boardTableView(forfeit, "a").status).toBe("Bob wins by forfeit");
expect(boardTableView(forfeit, "spectator").status).toBe("Bob wins by forfeit");
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/game/boardTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function statusFor(snapshot: BoardUpdatePayload, mySeat: 0 | 1 | null): string {
const result = snapshot.state?.result;
if (snapshot.reason === "forfeit") {
// Forfeit empties the leaver's seat, so anyone still seated is the winner.
if (mySeat !== null) return "Opponent left — you win!";
if (mySeat !== null) return "Opponent left. You win!";
const winnerSeat = snapshot.seats[0] ? 0 : snapshot.seats[1] ? 1 : null;
return `${winnerSeat !== null ? names[winnerSeat] ?? "Player" : "A player"} wins by forfeit`;
}
Expand Down
26 changes: 19 additions & 7 deletions frontend/src/game/chars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,44 @@
*
* Add a new entry here + drop the PNG in public/assets/characters/ to
* introduce a new avatar — no other code changes needed.
*
* char5/char7/char9 were removed as visual duplicates of char3/char4/char1;
* PNGs remain on disk so old localStorage values still resolve via aliases.
*/
export const CHARS = [
// Original four (Penzilla-style interior characters)
"char1",
"char2",
"char3",
"char4",
// Expanded roster: Pipoya FREE RPG Character Sprites 32x32
// Female 01, 02, 05, 07
"char5",
"char6",
"char7",
"char8",
// Male 01, 02, 06, 08
"char9",
"char10",
"char11",
"char12",
] as const;

export type CharKey = (typeof CHARS)[number];

/** Legacy picker keys that were visual duplicates of a keeper. */
const CHAR_ALIASES: Readonly<Record<string, CharKey>> = {
char5: "char3",
char7: "char4",
char9: "char1",
};

export function isCharKey(v: string): v is CharKey {
return (CHARS as readonly string[]).includes(v);
}

/**
* Map a stored or override avatar key onto a selectable sprite. Returns null
* for unknown keys so callers can fall back (e.g. charForPlayer).
*/
export function resolveCharKey(v: string): CharKey | null {
if (isCharKey(v)) return v;
return CHAR_ALIASES[v] ?? null;
}

function hash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/game/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ export function commandInsertion(command: ChatCommand): string {

export const CHAT_HELP = [
"Commands:",
...CHAT_COMMANDS.map((command) => `${command.usage} — ${command.description}`),
...CHAT_COMMANDS.map((command) => `${command.usage}: ${command.description}`),
"Enter or T focuses chat · Esc returns to the game",
] as const;
6 changes: 3 additions & 3 deletions frontend/src/game/chatCooldown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ describe("cooldownRetrySeconds", () => {

describe("chatCooldownNotice", () => {
it("names the whole-second wait derived from retryAfterMs", () => {
expect(chatCooldownNotice(4000)).toBe("You're sending messages too fast — wait 4s.");
expect(chatCooldownNotice(3200)).toBe("You're sending messages too fast — wait 4s.");
expect(chatCooldownNotice(0)).toBe("You're sending messages too fast — wait 1s.");
expect(chatCooldownNotice(4000)).toBe("You're sending messages too fast. Wait 4s.");
expect(chatCooldownNotice(3200)).toBe("You're sending messages too fast. Wait 4s.");
expect(chatCooldownNotice(0)).toBe("You're sending messages too fast. Wait 1s.");
});
});
2 changes: 1 addition & 1 deletion frontend/src/game/chatCooldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ export function cooldownRetrySeconds(retryAfterMs: number): number {

/** The system line shown in a chat transcript when a send is rate-limited. */
export function chatCooldownNotice(retryAfterMs: number): string {
return `You're sending messages too fast — wait ${cooldownRetrySeconds(retryAfterMs)}s.`;
return `You're sending messages too fast. Wait ${cooldownRetrySeconds(retryAfterMs)}s.`;
}
4 changes: 2 additions & 2 deletions frontend/src/game/controlBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { mediaFailureText } from "./controlBar";

describe("bounded media-failure copy (PRD 25.7)", () => {
it.each([
["mic", "denied", "Microphone blocked — allow access in your browser"],
["mic", "denied", "Microphone blocked: allow access in your browser"],
["mic", "unavailable", "No microphone found"],
["mic", "failed", "Couldn't turn on the microphone"],
["cam", "denied", "Camera blocked — allow access in your browser"],
["cam", "denied", "Camera blocked: allow access in your browser"],
["cam", "unavailable", "No camera found"],
["cam", "failed", "Couldn't turn on the camera"],
] as const)("%s %s -> %s", (device, failure, text) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/game/controlBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function mediaFailureText(
const noun = device === "mic" ? "Microphone" : "Camera";
switch (failure) {
case "denied":
return `${noun} blocked — allow access in your browser`;
return `${noun} blocked: allow access in your browser`;
case "unavailable":
return device === "mic" ? "No microphone found" : "No camera found";
case "failed":
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/game/modPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,15 @@ export type ModErrorCode =
export function moderationErrorText(code: ModErrorCode): string {
switch (code) {
case "validation":
return "That request was rejected — check the details and try again.";
return "That request was rejected. Check the details and try again.";
case "invalid-until":
return "Pick a suspension end time in the future.";
case "target-not-found":
return "That user no longer exists.";
case "not-found":
return "That report is no longer available — refresh the list.";
return "That report is no longer available. Refresh the list.";
case "rate-limited":
return "Too many moderation actions — wait a moment and try again.";
return "Too many moderation actions. Wait a moment and try again.";
case "unauthorized":
return "Your session is no longer authorized for moderation.";
case "network":
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/game/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ function errorNotice(code: ReportErrorCode): string {
case "message-not-found":
return "That message is too old to report.";
case "rate-limited":
return "You're reporting too fast — wait a moment and try again.";
return "You're reporting too fast. Wait a moment and try again.";
case "unauthorized":
return "Please sign in again to report.";
case "network":
return "Couldn't reach the server — check your connection and try again.";
return "Couldn't reach the server. Check your connection and try again.";
case "unknown":
default:
return "Couldn't send your report. Please try again.";
Expand All @@ -45,6 +45,6 @@ function errorNotice(code: ReportErrorCode): string {
export function reportResultNotice(result: ReportResult): string {
if (!result.ok) return errorNotice(result.code);
return result.status === "duplicate"
? "You've already reported that message — moderators have it."
: "Thanks — your report was sent to the moderators.";
? "You've already reported that message. Moderators have it."
: "Thanks. Your report was sent to the moderators.";
}
2 changes: 1 addition & 1 deletion frontend/src/game/roomAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const CAPACITY_MESSAGE = "This room is at max capacity.";
export function knockResultMessage(result: "denied" | "timeout" | "too-far"): string {
switch (result) {
case "timeout":
return "No answer — nobody let you in.";
return "No answer. Nobody let you in.";
case "too-far":
return "Step up to the door to knock.";
default:
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/game/scenes/WorldScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Net } from "../../net/net";
import { authToken } from "../../net/auth";
import { bus } from "../eventBus";
import { createCharAnims, idleFrame, walkAnim } from "../avatar";
import { CHARS, charForPlayer, isCharKey } from "../chars";
import { CHARS, charForPlayer, resolveCharKey } from "../chars";
import { activeMap } from "../maps";
import { parseInteractables, findNear, arcadeOpenPayload, type InteractableDef } from "../interactables";
import { movementIntent, BASE_SPEED } from "../movement";
Expand Down Expand Up @@ -227,7 +227,7 @@ export default class WorldScene extends Phaser.Scene {
});

const saved = localStorage.getItem("avatar");
this.avatar = saved && isCharKey(saved) ? saved : "char1";
this.avatar = saved ? (resolveCharKey(saved) ?? "char1") : "char1";

const mapKey = activeMap().key;
const map = this.make.tilemap({ key: mapKey });
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/net/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ function failureMessage(failure: AuthFailureResponse | null): string {
case "validation":
return "Check the username and password requirements, then try again.";
case "username-taken":
return "That username is taken — try signing in instead.";
return "That username is taken. Try signing in instead.";
case "invalid-credentials":
return "Sign in failed — check your username and password.";
return "Sign in failed. Check your username and password.";
case "rate-limited":
return `Too many attempts. Try again in ${failure.retryAfterSeconds} seconds.`;
case "server-error":
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/net/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const MISCONFIGURED = !USE_MOCK && !SERVER_URL;
export function assertServerUrl(): string {
if (!SERVER_URL)
throw new Error(
"VITE_SERVER_URL is required — production builds must point at the backend " +
"VITE_SERVER_URL is required: production builds must point at the backend " +
"(mock mode is development-only)."
);
return SERVER_URL;
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/ui/ArrivalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default function ArrivalPanel() {
className="arrival-head"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={headerCount === undefined ? "Who's around" : `Who's around — ${headerCount} online`}
aria-label={headerCount === undefined ? "Who's around" : `Who's around: ${headerCount} online`}
>
<Sparkles size={14} aria-hidden="true" />
<span>Around{headerCount !== undefined ? ` · ${headerCount}` : ""}</span>
Expand All @@ -87,9 +87,9 @@ export default function ArrivalPanel() {
<div className="arrival-body">
<p className="arrival-status" role="status" aria-live="polite">
{view.kind === "loading" && "Finding who's around…"}
{view.kind === "offline" && "You're offline — reconnecting to see who's around."}
{view.kind === "offline" && "You're offline. Reconnecting to see who's around."}
{view.kind === "failed" && "Couldn't load who's around. It'll update when you reconnect."}
{view.kind === "empty" && "Nobody else is around yet — walk out and explore, or start something."}
{view.kind === "empty" && "Nobody else is around yet. Walk out and explore, or start something."}
{view.kind === "active" && `${view.onlineCount} online right now.`}
</p>

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/ui/CampusHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* composition with no JS involved (the strollers/motes hide, the tint freezes).
*/

// Each stroller reuses one of the 12 in-game character spritesheets (96×128, a
// Each stroller reuses one of the 9 in-game character spritesheets (96×128, a
// 3-col × 4-row grid of 32×32 frames; row 1 = facing left, row 2 = facing right).
type Stroller = {
char: string;
Expand All @@ -22,8 +22,8 @@ type Stroller = {
};

const STROLLERS: Stroller[] = [
{ char: "char5", dir: "right", top: "58%", scale: 2.2, dur: "34s", delay: "0s" },
{ char: "char9", dir: "left", top: "70%", scale: 2.5, dur: "29s", delay: "-8s" },
{ char: "char8", dir: "right", top: "58%", scale: 2.2, dur: "34s", delay: "0s" },
{ char: "char1", dir: "left", top: "70%", scale: 2.5, dur: "29s", delay: "-8s" },
{ char: "char2", dir: "right", top: "80%", scale: 2.8, dur: "26s", delay: "-15s" },
];

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/ui/ChatBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,13 @@ describe("ChatBox persistent panel", () => {
it("surfaces a world-chat cooldown with retry timing instead of dropping silently", () => {
render(<ChatBox />);
act(() => netMock.net.emit("chat-cooldown", { scope: "world", retryAfterMs: 4000 }));
expect(screen.getByText("You're sending messages too fast — wait 4s.")).toBeTruthy();
expect(screen.getByText("You're sending messages too fast. Wait 4s.")).toBeTruthy();
});

it("surfaces a whisper cooldown in the transcript", () => {
render(<ChatBox />);
act(() => netMock.net.emit("chat-cooldown", { scope: "whisper", retryAfterMs: 2500 }));
expect(screen.getByText("You're sending messages too fast — wait 3s.")).toBeTruthy();
expect(screen.getByText("You're sending messages too fast. Wait 3s.")).toBeTruthy();
});

it("shows only populated moderation sections and closes after the final unmute", async () => {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/ui/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
}
);
const offFail = net.on("whisper-fail", () =>
push({ kind: "sys", text: "Couldn't deliver your whisper — they may have left." })
push({ kind: "sys", text: "Couldn't deliver your whisper. They may have left." })
);
// Anti-spam cooldown (PRD 25.11): the server refused an over-limit send. World
// and whisper both surface here; the meeting panel owns its own "meeting" line.
Expand Down Expand Up @@ -406,7 +406,7 @@
return panel.tab === "room" ? e.scope !== "world" : true;
}),
// modTick re-runs the filter when a mute/block toggles.
[entries, panel.tab, modTick]

Check warning on line 409 in frontend/src/ui/ChatBox.tsx

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Test · Build

React Hook useMemo has an unnecessary dependency: 'modTick'. Either exclude it or remove the dependency array
);

const renderLine = (e: Entry, i: number) => {
Expand Down Expand Up @@ -493,7 +493,7 @@
if (localModeration.mutedIds().length === 0 && localModeration.blockedIds().length === 0) {
setShowMod(false);
}
push({ kind: "sys", text: `Couldn't block ${name} — please try again.` });
push({ kind: "sys", text: `Couldn't block ${name}. Please try again.` });
}
};

Expand All @@ -505,12 +505,12 @@
setShowMod(false);
}
} else {
push({ kind: "sys", text: `Couldn't unblock ${name} — please try again.` });
push({ kind: "sys", text: `Couldn't unblock ${name}. Please try again.` });
}
};

const mutedList = useMemo(() => localModeration.mutedIds(), [modTick]);

Check warning on line 512 in frontend/src/ui/ChatBox.tsx

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Test · Build

React Hook useMemo has an unnecessary dependency: 'modTick'. Either exclude it or remove the dependency array
const blockedList = useMemo(() => localModeration.blockedIds(), [modTick]);

Check warning on line 513 in frontend/src/ui/ChatBox.tsx

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Test · Build

React Hook useMemo has an unnecessary dependency: 'modTick'. Either exclude it or remove the dependency array
const hasModeratedPlayers = mutedList.length > 0 || blockedList.length > 0;

const sendReport = async (target: ReportTarget, category: ReportCategory, note: string) => {
Expand Down Expand Up @@ -634,7 +634,7 @@
{visible.length === 0 ? (
<div className="mc-empty">
{panel.tab === "room"
? "Private area — only people here see these messages."
? "Private area: only people here see these messages."
: "Say hi to the space… (type /help for commands)"}
</div>
) : (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ui/ControlBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe("ControlBar", () => {
// ...then the confirmed denial reverts it to muted and announces the reason.
await waitFor(() => {
expect(screen.getByRole("status").textContent).toBe(
"Microphone blocked — allow access in your browser",
"Microphone blocked: allow access in your browser",
);
expect(screen.getByLabelText("Unmute microphone")).toBeTruthy();
});
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/ui/HelpOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import Dialog from "./Dialog";
const CONTROLS: [string, string][] = [
["Move", "WASD / Arrow keys"],
["Run", "Hold Shift"],
["Interact", "E — sit, stand, doors, tables, arcade"],
["Interact", "E: sit, stand, doors, tables, arcade"],
["Enter a room", "Walk to its door, type the key"],
["Fullscreen map", "M (Esc to close)"],
["Chat", "Enter or T — / for a command"],
["Chat", "Enter or T, / for a command"],
["Find someone", "Open Around (top-right), click a name"],
["Arcade games", "Arrow keys / WASD — Space to flap"],
["Arcade games", "Arrow keys / WASD, Space to flap"],
["Close overlay", "Escape"],
["Help", "Press ?"],
];
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/ui/Landing.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
import { signUp, signIn, USE_MOCK } from "../net/auth";
import { CHARS } from "../game/chars";
import { CHARS, resolveCharKey } from "../game/chars";
import Logo from "./Logo";
import CampusHero from "./CampusHero";
type Mode = "signin" | "signup";
Expand All @@ -23,7 +23,7 @@ export default function Landing({
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [avatar, setAvatar] = useState(
() => localStorage.getItem("avatar") ?? "char1"
() => resolveCharKey(localStorage.getItem("avatar") ?? "char1") ?? "char1"
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -177,10 +177,6 @@ export default function Landing({
<span>{cta}</span>
<ArrowRight className="console-submit-arrow" size={18} aria-hidden="true" />
</button>

<p className="console-foot">
WASD / arrows move · Shift runs · E sits · ? for help
</p>
</form>
</main>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ui/MeetingChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default function MeetingChatPanel({ lines, onSend, open, unread, onToggle
</header>
<div className="meeting-chat-list" data-testid="meeting-chat-list" ref={listRef}>
{lines.length === 0 ? (
<p className="meeting-chat-empty">No messages yet — say hi</p>
<p className="meeting-chat-empty">No messages yet. Say hi</p>
) : (
lines.map((line) => (
<p key={line.key} className={`meeting-chat-line${line.self ? " self" : ""}`}>
Expand Down
Loading
Loading