diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e57c9f1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git
+.env
+**/node_modules
+**/dist
+**/coverage
+**/*.log
+
+apps/web/src
+apps/web/public
+programs
+tests
+tools
+avatar_images
diff --git a/.env.example b/.env.example
index b3e29dc..a6e145e 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,7 @@
NEXT_PUBLIC_WORLD_WS=ws://localhost:2567
VITE_WORLD_WS=ws://localhost:2567
-VITE_WORLD_HTTP=http://localhost:2567
+VITE_API_BASE_URL=http://localhost:2567
+FRONTEND_BASE_URL=http://localhost:5173
# Durable Match accounts and wallet joins use base Solana; private PER reads use MagicBlock TEE.
OUTCRY_BASE_RPC=https://api.devnet.solana.com
OUTCRY_DEPLOY_RPC=https://api.devnet.solana.com
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0e9dd05..7c393e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,9 +16,13 @@ jobs:
with:
node-version: 24
cache: pnpm
+ - uses: solana-foundation/github-actions/setup-all@v0.2.15
+ with:
+ solana_version: 3.1.10
+ anchor_version: 1.0.2
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: NO_DNA=1 anchor build
- - run: NO_DNA=1 anchor test
+ - run: NO_DNA=1 anchor test --skip-deploy --skip-build --skip-lint
diff --git a/.gitignore b/.gitignore
index 84bdc9e..7a13aeb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
docs/*
!docs/
-!docs/AUTHORITY.md
node_modules
.pnpm-store
target
@@ -9,3 +8,4 @@ dist
.env
artifacts
*.md
+!docs/AUTHORITY.md
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 5b829d9..88ebe89 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import WorldCanvas from "./world/WorldCanvas";
+import { API_BASE_URL } from "./config";
type WorldCard = {
id: string;
@@ -16,9 +17,6 @@ const initialWorlds: WorldCard[] = [
{ id: "kyoto-lanterns", name: "Kyoto Lanterns", online: 0, pits: 2, ready: true },
];
-const worldHttp = import.meta.env.VITE_WORLD_HTTP ||
- (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");
-
function worldFromUrl() {
const id = new URLSearchParams(window.location.search).get("world");
return initialWorlds.some((world) => world.ready && world.id === id) ? id : null;
@@ -42,7 +40,7 @@ export default function App() {
let active = true;
const refreshWorlds = async () => {
try {
- const response = await fetch(`${worldHttp}/api/worlds`);
+ const response = await fetch(`${API_BASE_URL}/api/worlds`);
if (!response.ok) return;
const payload = await response.json() as { worlds?: Array<{ id: string; online: number; activePits: number }> };
if (!active || !Array.isArray(payload.worlds)) return;
diff --git a/apps/web/src/chain/idl/outcry.json b/apps/web/src/chain/idl/outcry.json
index ee11610..373ebb6 100644
--- a/apps/web/src/chain/idl/outcry.json
+++ b/apps/web/src/chain/idl/outcry.json
@@ -1548,7 +1548,10 @@
},
{
"name": "authority",
- "signer": true
+ "signer": true,
+ "relations": [
+ "match_state"
+ ]
}
],
"args": []
@@ -1909,7 +1912,10 @@
{
"name": "authority",
"writable": true,
- "signer": true
+ "signer": true,
+ "relations": [
+ "match_state"
+ ]
},
{
"name": "system_program",
@@ -3197,7 +3203,10 @@
},
{
"name": "authority",
- "signer": true
+ "signer": true,
+ "relations": [
+ "match_state"
+ ]
}
],
"args": [
@@ -3341,7 +3350,10 @@
},
{
"name": "authority",
- "signer": true
+ "signer": true,
+ "relations": [
+ "match_state"
+ ]
},
{
"name": "magic_program",
diff --git a/apps/web/src/chain/matchActions.ts b/apps/web/src/chain/matchActions.ts
index 1ca2637..cdbea41 100644
--- a/apps/web/src/chain/matchActions.ts
+++ b/apps/web/src/chain/matchActions.ts
@@ -93,6 +93,28 @@ function integerBytes(value: number | bigint, signed = false) {
return data;
}
+function startMatchSimulationError(value: { err: unknown; logs?: string[] | null; unitsConsumed?: number | null }, instructionLabels: string[]) {
+ const instructionError = typeof value.err === "object" && value.err !== null && "InstructionError" in value.err
+ ? (value.err as { InstructionError?: unknown }).InstructionError
+ : undefined;
+ const failedInstructionIndex = Array.isArray(instructionError) && typeof instructionError[0] === "number"
+ ? instructionError[0]
+ : undefined;
+ const failedInstruction = failedInstructionIndex === undefined
+ ? "unknown_instruction"
+ : instructionLabels[failedInstructionIndex] ?? `instruction_${failedInstructionIndex}`;
+ const logs = value.logs ?? [];
+ const relevantLog = [...logs].reverse().find((log) => /AnchorError|Error Code|failed|custom program error|constraint/i.test(log));
+ console.error("[outcry][match-start][simulation]", {
+ failedInstructionIndex,
+ failedInstruction,
+ err: value.err,
+ logs,
+ unitsConsumed: value.unitsConsumed ?? null,
+ });
+ return `match_start_bundle_simulation_failed:${failedInstruction}:${relevantLog ?? JSON.stringify(value.err)}`;
+}
+
function quoteSessionKey(input: { baseRpcUrl: string; matchAddress: string; dealer: PublicKey; programId: PublicKey }) {
return [input.baseRpcUrl, input.programId.toBase58(), input.matchAddress, input.dealer.toBase58()].join(":");
}
@@ -398,9 +420,15 @@ export async function startMatchOnchain(input: {
const connected = wallet.publicKey ? { publicKey: wallet.publicKey } : await wallet.connect();
const authority = new PublicKey(connected.publicKey);
if (authority.toBase58() !== input.hostAddress) throw new Error("host_wallet_mismatch");
+ const programId = new PublicKey(input.programId ?? PROGRAM_ID);
const connection = createBaseRpcConnection(input.rpcUrl);
const matchInfo = await connection.getAccountInfo(new PublicKey(input.matchAddress), "confirmed");
- if (!matchInfo) throw new Error("match_account_unavailable");
+ if (!matchInfo?.owner.equals(programId)) throw new Error("match_account_unavailable");
+ const matchSnapshot = decodePublicMatchAccount(input.matchAddress, matchInfo.data);
+ if (matchSnapshot.authority !== authority.toBase58()) {
+ throw new Error(`match_authority_wallet_mismatch:connect_${matchSnapshot.authority}`);
+ }
+ if (!matchSnapshot.players.includes(authority.toBase58())) throw new Error("match_authority_not_seated");
const transaction = new Transaction();
if (matchInfo.data.length < CURRENT_MATCH_BYTES) {
transaction.add(createMigrateLegacyMatchInstruction({ ...input, hostAddress: authority.toBase58() }));
@@ -427,10 +455,13 @@ export async function startMatchOnchain(input: {
transaction.recentBlockhash = blockhash.blockhash;
transaction.lastValidBlockHeight = blockhash.lastValidBlockHeight;
const simulation = await connection.simulateTransaction(transaction);
- if (simulation.value.err) {
- const relevantLog = simulation.value.logs?.find((log) => /Error|failed|constraint|invalid|insufficient/i.test(log));
- throw new Error(`start_match_simulation_failed: ${relevantLog ?? JSON.stringify(simulation.value.err)}`);
- }
+ if (simulation.value.err) throw new Error(startMatchSimulationError(simulation.value, [
+ ...(matchInfo.data.length < CURRENT_MATCH_BYTES ? ["migrate_legacy_match"] : []),
+ "start_match",
+ "initialize_escrow",
+ "prepare_rfq_round",
+ "initialize_match_result",
+ ]));
const sent = await wallet.signAndSendTransaction(transaction);
const signature = typeof sent === "string" ? sent : sent.signature;
await connection.confirmTransaction({ signature, blockhash: blockhash.blockhash, lastValidBlockHeight: blockhash.lastValidBlockHeight }, "confirmed");
diff --git a/apps/web/src/chain/matchState.test.ts b/apps/web/src/chain/matchState.test.ts
index 719108e..ef299e6 100644
--- a/apps/web/src/chain/matchState.test.ts
+++ b/apps/web/src/chain/matchState.test.ts
@@ -33,6 +33,12 @@ assert.equal(snapshot.taker, players[0].toBase58());
assert.equal(snapshot.playerCount, 4);
assert.equal(snapshot.roundCount, 3);
assert.deepEqual(snapshot.players, players.map((player) => player.toBase58()));
+const authorityAfterAnotherPlayerJoined = players[1];
+const authorityFirstSnapshotData = new Uint8Array(data);
+authorityFirstSnapshotData.set(authorityAfterAnotherPlayerJoined.toBytes(), 8);
+const authorityFirstSnapshot = decodePublicMatchAccount(match.toBase58(), authorityFirstSnapshotData);
+assert.equal(authorityFirstSnapshot.host, authorityAfterAnotherPlayerJoined.toBase58());
+assert.equal(authorityFirstSnapshot.taker, authorityAfterAnotherPlayerJoined.toBase58());
const resolvedData = new Uint8Array(data);
resolvedData[83] = 1;
resolvedData[374] = 0;
diff --git a/apps/web/src/chain/matchState.ts b/apps/web/src/chain/matchState.ts
index def9aad..38f22cd 100644
--- a/apps/web/src/chain/matchState.ts
+++ b/apps/web/src/chain/matchState.ts
@@ -80,6 +80,12 @@ function readI64(data: Uint8Array, offset: number) {
return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
}
+function takerForRound(players: string[], authority: string, currentRound: number) {
+ const hostIndex = players.indexOf(authority);
+ const startingIndex = hostIndex >= 0 ? hostIndex : 0;
+ return players[(startingIndex + currentRound) % players.length];
+}
+
function assertAccount(data: Uint8Array, discriminator: Uint8Array, minimumLength: number, label: string) {
if (data.length < minimumLength || !sameBytes(data.slice(0, 8), discriminator)) throw new Error(`${label}_account_invalid`);
}
@@ -149,11 +155,9 @@ export function decodePublicMatchAccount(matchAddress: string, data: Uint8Array)
snapshot.lastRoundWinner = lastRoundWinner.toBase58();
}
}
+ snapshot.host = snapshot.authority;
if (snapshot.status === "STARTED" && snapshot.playerCount > 0) {
- snapshot.host = snapshot.players[0];
- snapshot.taker = snapshot.players[snapshot.currentRound % snapshot.playerCount];
- } else if (snapshot.playerCount > 0) {
- snapshot.host = snapshot.players[0];
+ snapshot.taker = takerForRound(snapshot.players, snapshot.authority, snapshot.currentRound);
}
return snapshot;
}
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
new file mode 100644
index 0000000..fc8c87f
--- /dev/null
+++ b/apps/web/src/config.ts
@@ -0,0 +1,6 @@
+const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL ||
+ import.meta.env.VITE_WORLD_HTTP ||
+ (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");
+
+export const API_BASE_URL = configuredApiBaseUrl.replace(/\/+$/, "");
+export const WORLD_WS_URL = import.meta.env.VITE_WORLD_WS || API_BASE_URL.replace(/^http/, "ws");
diff --git a/apps/web/src/match/TradeIntentPanel.tsx b/apps/web/src/match/TradeIntentPanel.tsx
index 59d33fd..bbf4427 100644
--- a/apps/web/src/match/TradeIntentPanel.tsx
+++ b/apps/web/src/match/TradeIntentPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { parseTradeIntent, type TradeIntent } from "./tradeIntent";
+import { API_BASE_URL } from "../config";
type Props = {
active: boolean;
@@ -11,9 +12,6 @@ type Props = {
submissionStatus?: string;
};
-const worldHttp = import.meta.env.VITE_WORLD_HTTP ||
- (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");
-
function speechError(reason: unknown) {
const detail = reason instanceof Error
? reason.message
@@ -135,7 +133,7 @@ export default function TradeIntentPanel({ active, matchId, role = "PLAYER", ses
}
setTranscribing(true);
try {
- const response = await fetch(`${worldHttp}/api/speech/transcribe`, {
+ const response = await fetch(`${API_BASE_URL}/api/speech/transcribe`, {
method: "POST",
headers: {
"content-type": audio.type || "audio/webm",
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 4e52ef3..863a8b8 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -98,7 +98,7 @@ body {
background: #0b0b0f;
}
-.world-shell > div {
+.world-shell > .world-game-mount {
position: absolute;
inset: 0;
}
@@ -139,6 +139,66 @@ body {
color: #ffb347;
}
+.world-chat {
+ position: absolute;
+ z-index: 12;
+ right: 1rem;
+ bottom: 1rem;
+ display: grid;
+ gap: 0.35rem;
+ width: min(23rem, calc(100% - 2rem));
+ padding: 0.65rem;
+ color: #f4f1ea;
+ background: #11151df2;
+ border: 2px solid #2b3546;
+ box-shadow: 4px 4px 0 #0b0b0f;
+ font: 0.65rem monospace;
+ image-rendering: pixelated;
+}
+
+.world-chat label {
+ color: #ffb347;
+ letter-spacing: 0.12em;
+ font-weight: 700;
+}
+
+.world-chat-row {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.world-chat input {
+ min-width: 0;
+ flex: 1;
+ padding: 0.6rem;
+ color: #0b0b0f;
+ background: #f4f1ea;
+ border: 2px solid #0b0b0f;
+ outline: 0;
+ font: 700 0.75rem monospace;
+}
+
+.world-chat input:focus {
+ border-color: #ffb347;
+ box-shadow: 2px 2px 0 #0b0b0f;
+}
+
+.world-chat button {
+ padding: 0.55rem 0.7rem;
+ border: 2px solid #0b0b0f;
+ box-shadow: 2px 2px 0 #0b0b0f;
+ font: 700 0.7rem monospace;
+}
+
+.world-chat button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.world-chat span {
+ color: #a9b3c6;
+}
+
.pit-overlay {
position: absolute;
z-index: 20;
diff --git a/apps/web/src/world/PitOverlay.test.ts b/apps/web/src/world/PitOverlay.test.ts
index 6fda1ea..c27f43a 100644
--- a/apps/web/src/world/PitOverlay.test.ts
+++ b/apps/web/src/world/PitOverlay.test.ts
@@ -52,6 +52,8 @@ assert.match(source, /VITE_OUTCRY_MATCH_NONCE/);
assert.match(source, /nonce: currentMatchNonce/);
assert.match(source, /match_account_not_initialized/);
assert.match(source, /onChainSeatConflict/);
+assert.match(source, /onWalletJoinStarted/);
+assert.match(source, /awaiting_confirmation/);
assert.match(source, /Restoring that seat/);
assert.match(source, /Restore match to Base \(host\)/);
assert.match(source, /restoreDelegatedMatchOnchain/);
diff --git a/apps/web/src/world/PitOverlay.tsx b/apps/web/src/world/PitOverlay.tsx
index 7516b12..29636e9 100644
--- a/apps/web/src/world/PitOverlay.tsx
+++ b/apps/web/src/world/PitOverlay.tsx
@@ -15,12 +15,14 @@ import MatchHud from "../match/MatchHud";
import PrivateInventoryPanel from "../match/PrivateInventoryPanel";
import PrivateQuotePanel from "../match/PrivateQuotePanel";
import TradeIntentPanel from "../match/TradeIntentPanel";
+import { API_BASE_URL } from "../config";
type Props = {
matchId: string;
matchAddress?: string;
chainConfirmed?: boolean;
onChainConfirmed: (input: { matchAddress: string; walletAddress: string }) => void;
+ onWalletJoinStarted: () => void;
onMatchAddressChanged?: (matchAddress: string) => void;
onChainSeatConflict?: (input: { matchAddress: string; walletAddress: string }) => void;
role: "PLAYER" | "SPECTATOR";
@@ -35,9 +37,6 @@ type TokenResponse = {
error?: string;
};
-const worldHttp = import.meta.env.VITE_WORLD_HTTP ||
- (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http");
-
// Match accounts and wallet instructions live on Solana's durable base layer.
const baseSolanaRpc = browserBaseRpc.url;
const teeSolanaRpc = import.meta.env.VITE_MAGICBLOCK_TEE_RPC || "https://devnet-tee.magicblock.app";
@@ -129,7 +128,7 @@ function settlementError(reason: unknown) {
}
async function fetchSolUsdPriceUpdates(input: { matchId: string; sessionId: string }) {
- const response = await fetch(`${worldHttp}/api/oracle/sol-usd-update`, {
+ const response = await fetch(`${API_BASE_URL}/api/oracle/sol-usd-update`, {
headers: {
"x-outcry-match-id": input.matchId,
"x-outcry-session-id": input.sessionId,
@@ -143,12 +142,12 @@ async function fetchSolUsdPriceUpdates(input: { matchId: string; sessionId: stri
return payload.updates;
}
-export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onChainConfirmed, onMatchAddressChanged, onChainSeatConflict, onExit, role, seatIndex, sessionId }: Props) {
+export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onChainConfirmed, onWalletJoinStarted, onMatchAddressChanged, onChainSeatConflict, onExit, role, seatIndex, sessionId }: Props) {
const room = useMemo(() => new Room({ adaptiveStream: true, dynacast: true }), []);
const matchConnection = useMemo(() => baseSolanaRpc ? createBaseRpcConnection(baseSolanaRpc) : undefined, []);
// Match/RfqRound are public metadata. Private state remains on authenticated TEE connections.
const teeMatchConnection = useMemo(() => new Connection(teeSolanaRpc, { commitment: "confirmed", disableRetryOnRateLimit: true }), []);
- const [chainPhase, setChainPhase] = useState<"required" | "joining" | "confirmed" | "failed">(
+ const [chainPhase, setChainPhase] = useState<"required" | "joining" | "awaiting_confirmation" | "confirmed" | "failed">(
role === "PLAYER" && matchAddress && !chainConfirmed ? "required" : "confirmed",
);
const [phase, setPhase] = useState<"connecting" | "connected" | "reconnecting" | "disconnected" | "error">("connecting");
@@ -179,6 +178,10 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
const walletAddress = connectedWalletAddress();
const chainActionInFlight = startingMatch || preparingRound || settling || resolving || skippingRound || resumingSkippedRound || releasingStaleMatch || restoringMatch || submittingRfq || chainPhase === "joining";
+ useEffect(() => {
+ if (chainConfirmed) setChainPhase("confirmed");
+ }, [chainConfirmed]);
+
useEffect(() => {
let active = true;
if (!baseSolanaRpc || role !== "PLAYER" || !matchAddress || chainPhase === "confirmed") {
@@ -283,7 +286,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
setPhase("connecting");
setError("");
try {
- const response = await fetch(`${worldHttp}/api/livekit/token`, {
+ const response = await fetch(`${API_BASE_URL}/api/livekit/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ matchId, role, sessionId }),
@@ -444,6 +447,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
setChainPhase("joining");
setError("");
setActiveMatchToRelease(undefined);
+ onWalletJoinStarted();
try {
let result;
try {
@@ -468,7 +472,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
} else {
onChainConfirmed({ matchAddress, walletAddress: result.walletAddress });
}
- setChainPhase("confirmed");
+ setChainPhase("awaiting_confirmation");
} catch (reason) {
if (reason instanceof Error && reason.message.startsWith("pit_has_active_match:")) {
setActiveMatchToRelease(reason.message.slice("pit_has_active_match:".length));
@@ -659,7 +663,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
}
};
- const needsChainJoin = role === "PLAYER" && Boolean(matchAddress) && chainPhase !== "confirmed";
+ const needsChainJoin = role === "PLAYER" && Boolean(matchAddress) && chainPhase !== "confirmed" && chainPhase !== "awaiting_confirmation";
return (
@@ -700,6 +704,11 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh
)}
+ ) : chainPhase === "awaiting_confirmation" ? (
+
+
Wallet approved. Verifying your onchain seat before entering the media room…
+
+
) : (
<>
(null);
@@ -23,6 +36,38 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
const [connectionState, setConnectionState] = useState("connecting");
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [pit, setPit] = useState();
+ const [chatDraft, setChatDraft] = useState("");
+ const chatInputRef = useRef(null);
+
+ const requestReconnect = () => {
+ if (intentionalExitRef.current || reconnectRequestedRef.current) return;
+ reconnectRequestedRef.current = true;
+ setConnectionState("reconnecting");
+ setReconnectAttempt((value) => value + 1);
+ };
+
+ const sendRoomMessage = (type: string, payload?: unknown) => {
+ const room = roomRef.current;
+ if (!room || !room.connection.isOpen) {
+ requestReconnect();
+ return false;
+ }
+ try {
+ room.send(type, payload);
+ return true;
+ } catch {
+ requestReconnect();
+ return false;
+ }
+ };
+
+ const sendChat = (event: FormEvent) => {
+ event.preventDefault();
+ const text = chatDraft.trim();
+ if (!text || !sendRoomMessage("chat", { text })) return;
+ setChatDraft("");
+ chatInputRef.current?.blur();
+ };
useEffect(() => {
if (!mountRef.current) return;
@@ -31,19 +76,21 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
let game: Phaser.Game | undefined;
let room: WorldRoom | undefined;
let removeRoomListeners: (() => void) | undefined;
- const client = new Client(import.meta.env.VITE_WORLD_WS || "ws://localhost:2567");
+ const client = new Client(WORLD_WS_URL);
client
.joinOrCreate("world", { worldId }, WorldState)
.then((joinedRoom) => {
if (disposed) {
- void joinedRoom.leave();
+ leaveWorldRoom(joinedRoom);
return;
}
preservedGameRef.current?.destroy(true);
preservedGameRef.current = undefined;
room = joinedRoom;
roomRef.current = joinedRoom;
+ joinedRoom.reconnection.minUptime = 0;
+ joinedRoom.reconnection.maxRetries = 8;
reconnectRequestedRef.current = false;
if (reconnectAttempt > 0) setPit(undefined);
const onRoomError = (code: number, message?: string) => {
@@ -59,11 +106,12 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
};
const onRoomLeave = () => {
if (disposed) return;
+ if (roomRef.current === joinedRoom) roomRef.current = undefined;
setConnectionState("reconnecting");
reconnectRequestedRef.current = true;
reconnectTimer = window.setTimeout(() => {
if (!disposed) setReconnectAttempt((value) => value + 1);
- }, 1_000);
+ }, RECONNECT_FALLBACK_DELAY_MS);
};
joinedRoom.onError(onRoomError);
joinedRoom.onDrop(onRoomDrop);
@@ -84,6 +132,7 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
game = createWorldGame(mountRef.current!, worldId, room, {
onSeatEnter: setPit,
onSeatExit: () => setPit(undefined),
+ onConnectionLost: requestReconnect,
});
})
.catch((error: unknown) => {
@@ -105,14 +154,14 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
disposed = true;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
removeRoomListeners?.();
- void room?.leave();
+ if (roomRef.current === room) roomRef.current = undefined;
+ if (room) leaveWorldRoom(room);
if (preserveForReconnect) {
if (game) {
game.scene.pause("WorldScene");
preservedGameRef.current = game;
}
} else {
- roomRef.current = undefined;
game?.destroy(true);
preservedGameRef.current?.destroy(true);
preservedGameRef.current = undefined;
@@ -131,13 +180,31 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
{connectionState === "reconnecting" && "↻ Multiplayer reconnecting · match state remains available"}
{connectionState === "offline" && "Multiplayer offline · run pnpm dev:world"}
-
+
+
{pit && roomRef.current && (
roomRef.current?.send("confirmSeat", {
+ onWalletJoinStarted={() => sendRoomMessage("beginConfirm")}
+ onChainConfirmed={({ matchAddress, walletAddress }) => sendRoomMessage("confirmSeat", {
confirmed: true,
matchAddress,
walletAddress,
@@ -147,11 +214,11 @@ export default function WorldCanvas({ worldId, onExit }: Props) {
matchAddress,
chainConfirmed: false,
} : current)}
- onChainSeatConflict={({ matchAddress, walletAddress }) => roomRef.current?.send("reconcileSeat", {
+ onChainSeatConflict={({ matchAddress, walletAddress }) => sendRoomMessage("reconcileSeat", {
matchAddress,
walletAddress,
})}
- onExit={() => roomRef.current?.send("releaseSeat")}
+ onExit={() => sendRoomMessage("releaseSeat")}
role="PLAYER"
seatIndex={pit.seatIndex}
sessionId={roomRef.current.sessionId}
diff --git a/apps/web/src/world/WorldScene.test.ts b/apps/web/src/world/WorldScene.test.ts
index d81553e..e015001 100644
--- a/apps/web/src/world/WorldScene.test.ts
+++ b/apps/web/src/world/WorldScene.test.ts
@@ -3,5 +3,14 @@ import { readFile } from "node:fs/promises";
const source = await readFile(new URL("./WorldScene.ts", import.meta.url), "utf8");
assert.match(source, /canMove && this\.room\.connection\.isOpen/);
+assert.match(source, /if \(player\.mode === "RECONNECTING"\)/);
+assert.match(source, /this\.remotePlayers\.delete\(sessionId\)/);
+assert.match(source, /private sendRoomMessage\(type: string/);
+assert.match(source, /onConnectionLost\?\.\(\)/);
assert.match(source, /chainConfirmed: value\.action === "confirmed" \|\| value\.action === "restored"/);
+assert.match(source, /localState\?\.mode === "RESERVING"/);
+assert.match(source, /onMessage\("chat"/);
+assert.match(source, /CHAT_BUBBLE_DURATION_MS/);
+assert.match(source, /fillPoints\(points, true\)/);
+assert.doesNotMatch(source, /if \(!this\.room\?\.state\.players\.has\(value\.sessionId\)\) return;/);
console.log("world scene: input send is gated while the Colyseus socket is closed");
diff --git a/apps/web/src/world/WorldScene.ts b/apps/web/src/world/WorldScene.ts
index 6cb6a09..d34f24c 100644
--- a/apps/web/src/world/WorldScene.ts
+++ b/apps/web/src/world/WorldScene.ts
@@ -1,4 +1,5 @@
import Phaser from "phaser";
+import { MAX_CHAT_MESSAGE_LENGTH } from "@outcry/shared/domain";
import type { PlayerState, WorldState } from "@outcry/shared/world-state";
import type { WorldRoom } from "./createWorldGame";
import avatar01 from "../../../../avatar_images/avatar_01_walk.png";
@@ -13,6 +14,8 @@ import avatar09 from "../../../../avatar_images/avatar_09_walk.png";
import avatar10 from "../../../../avatar_images/avatar_10_walk.png";
const PLAYER_SPEED = 150;
+const CHAT_BUBBLE_DURATION_MS = 4_500;
+const CHAT_BUBBLE_MAX_WIDTH = 132;
const DECOR_COLLISIONS = {
tree: { width: 24, height: 16 },
bench: { width: 56, height: 14 },
@@ -42,6 +45,11 @@ type RemotePlayer = {
avatarIndex: number;
};
+type ChatBubble = {
+ container: Phaser.GameObjects.Container;
+ expiresAt: number;
+};
+
type InteractiveSeat = {
pitId: string;
seatIndex: number;
@@ -54,6 +62,7 @@ type InteractiveSeat = {
export type WorldSceneCallbacks = {
onSeatEnter?: (seat: { pitId: string; seatIndex: number; matchAddress?: string; chainConfirmed?: boolean }) => void;
onSeatExit?: () => void;
+ onConnectionLost?: () => void;
};
export class WorldScene extends Phaser.Scene {
@@ -72,6 +81,7 @@ export class WorldScene extends Phaser.Scene {
private feedbackUntil = 0;
private inputSequence = 0;
private readonly remotePlayers = new Map();
+ private readonly chatBubbles = new Map();
private interactiveSeats: InteractiveSeat[] = [];
private seatOverlayOpen = false;
private playerAvatarIndex = 0;
@@ -80,7 +90,7 @@ export class WorldScene extends Phaser.Scene {
super({ key: "WorldScene" });
this.worldId = worldId;
this.mapSlug = worldId;
- this.usesRasterBackground = this.mapSlug !== "wall-street";
+ this.usesRasterBackground = true;
this.room = room;
this.callbacks = callbacks;
}
@@ -118,7 +128,7 @@ export class WorldScene extends Phaser.Scene {
}
const decor = map.getObjectLayer("objects_decor")?.objects ?? [];
- this.renderDecor(decor);
+ if (!this.usesRasterBackground) this.renderDecor(decor);
const pits = map.getObjectLayer("objects_pits")?.objects ?? [];
const seats = map.getObjectLayer("objects_seats")?.objects ?? [];
const pitById = new Map(
@@ -208,6 +218,7 @@ export class WorldScene extends Phaser.Scene {
if (this.room) {
this.room.onStateChange((state) => this.syncServerState(state));
this.room.onMessage("seat", (result) => this.handleSeatResult(result));
+ this.room.onMessage("chat", (payload) => this.showChatBubble(payload));
this.syncServerState(this.room.state);
}
if (!this.usesRasterBackground) {
@@ -254,7 +265,7 @@ export class WorldScene extends Phaser.Scene {
const down = this.cursors.down.isDown || this.wasd.S.isDown;
const velocity = new Phaser.Math.Vector2(Number(right) - Number(left), Number(down) - Number(up));
if (this.room && canMove && this.room.connection.isOpen) {
- this.room.send("input", {
+ this.sendRoomMessage("input", {
seq: this.inputSequence++,
left,
right,
@@ -290,7 +301,7 @@ export class WorldScene extends Phaser.Scene {
if (localState && this.player) {
this.player.setPosition(localState.x, localState.y);
this.player.setDepth(localState.y);
- if (this.player.body) this.player.body.enable = localState.mode !== "SEATED";
+ if (this.player.body) this.player.body.enable = localState.mode === "WALKING";
if (this.seatOverlayOpen && localState.mode === "WALKING") {
this.seatOverlayOpen = false;
this.callbacks.onSeatExit?.();
@@ -300,6 +311,11 @@ export class WorldScene extends Phaser.Scene {
const seen = new Set();
state.players.forEach((player, sessionId) => {
if (sessionId === localId) return;
+ if (player.mode === "RECONNECTING") {
+ this.remotePlayers.get(sessionId)?.sprite.destroy();
+ this.remotePlayers.delete(sessionId);
+ return;
+ }
seen.add(sessionId);
const remote = this.remotePlayers.get(sessionId);
if (remote) {
@@ -339,7 +355,17 @@ export class WorldScene extends Phaser.Scene {
.setText(`${localState.pitId} · seat ${localState.seatIndex} · press R to leave`)
.setVisible(true);
if (this.releaseKey && Phaser.Input.Keyboard.JustDown(this.releaseKey)) {
- this.room.send("releaseSeat");
+ this.sendRoomMessage("releaseSeat");
+ }
+ return;
+ }
+
+ if (localState?.mode === "RESERVING") {
+ this.interactionText
+ .setText(`${localState.pitId} · seat ${localState.seatIndex} · confirm wallet join · press R to cancel`)
+ .setVisible(true);
+ if (this.releaseKey && Phaser.Input.Keyboard.JustDown(this.releaseKey)) {
+ this.sendRoomMessage("releaseSeat");
}
return;
}
@@ -357,7 +383,21 @@ export class WorldScene extends Phaser.Scene {
.setText(`Press E to sit · ${seat.pitId} · seat ${seat.seatIndex}`)
.setVisible(true);
if (this.interactKey && Phaser.Input.Keyboard.JustDown(this.interactKey)) {
- this.room.send("interact", { pitId: seat.pitId, seatIndex: seat.seatIndex });
+ this.sendRoomMessage("interact", { pitId: seat.pitId, seatIndex: seat.seatIndex });
+ }
+ }
+
+ private sendRoomMessage(type: string, payload?: unknown) {
+ if (!this.room || !this.room.connection.isOpen) {
+ this.callbacks.onConnectionLost?.();
+ return false;
+ }
+ try {
+ this.room.send(type, payload);
+ return true;
+ } catch {
+ this.callbacks.onConnectionLost?.();
+ return false;
}
}
@@ -420,6 +460,88 @@ export class WorldScene extends Phaser.Scene {
remote.sprite.y = Phaser.Math.Linear(remote.sprite.y, remote.targetY, 0.35);
remote.sprite.setDepth(remote.sprite.y);
}
+ this.updateChatBubbles();
+ }
+
+ private showChatBubble(payload: unknown) {
+ if (!payload || typeof payload !== "object") return;
+ const value = payload as { sessionId?: unknown; text?: unknown };
+ if (typeof value.sessionId !== "string" || typeof value.text !== "string") return;
+ const text = value.text.replace(/\s+/g, " ").trim().slice(0, MAX_CHAT_MESSAGE_LENGTH);
+ if (!text) return;
+
+ this.chatBubbles.get(value.sessionId)?.container.destroy();
+ const textObject = this.add.text(0, 0, text, {
+ color: "#0b0b0f",
+ fontFamily: "monospace",
+ fontSize: "10px",
+ fontStyle: "bold",
+ align: "center",
+ lineSpacing: 1,
+ wordWrap: { width: CHAT_BUBBLE_MAX_WIDTH - 18 },
+ }).setOrigin(0.5, 0);
+ const paddingX = 9;
+ const paddingY = 6;
+ const width = Math.min(
+ CHAT_BUBBLE_MAX_WIDTH,
+ Math.max(48, Math.ceil(textObject.width) + paddingX * 2),
+ );
+ const height = Math.ceil(textObject.height) + paddingY * 2;
+ const left = -width / 2;
+ const right = width / 2;
+ const top = -height - 10;
+ const bottom = -10;
+ const points = [
+ { x: left + 4, y: top },
+ { x: right - 4, y: top },
+ { x: right - 4, y: top + 3 },
+ { x: right, y: top + 3 },
+ { x: right, y: bottom - 4 },
+ { x: right - 4, y: bottom - 4 },
+ { x: right - 4, y: bottom },
+ { x: 4, y: bottom },
+ { x: 0, y: bottom + 7 },
+ { x: -4, y: bottom },
+ { x: left + 4, y: bottom },
+ { x: left + 4, y: bottom - 4 },
+ { x: left, y: bottom - 4 },
+ { x: left, y: top + 3 },
+ { x: left + 4, y: top + 3 },
+ ];
+ const graphics = this.add.graphics();
+ graphics.fillStyle(0x0b0b0f, 0.8).fillPoints(points.map(({ x, y }) => ({ x: x + 2, y: y + 3 })), true);
+ graphics.fillStyle(0xf4f1ea, 1).fillPoints(points, true);
+ graphics.lineStyle(2, 0x0b0b0f, 1).strokePoints(points, true);
+ graphics.fillStyle(0xffb347, 1).fillRect(-3, bottom - 2, 6, 2);
+ textObject.setPosition(0, top + paddingY);
+ const container = this.add.container(0, 0, [graphics, textObject]);
+ container.setDepth(10_000);
+ this.chatBubbles.set(value.sessionId, {
+ container,
+ expiresAt: this.time.now + CHAT_BUBBLE_DURATION_MS,
+ });
+ this.updateChatBubbles();
+ }
+
+ private updateChatBubbles() {
+ for (const [sessionId, bubble] of this.chatBubbles) {
+ if (this.time.now >= bubble.expiresAt) {
+ bubble.container.destroy();
+ this.chatBubbles.delete(sessionId);
+ continue;
+ }
+ const anchor = sessionId === this.room?.sessionId
+ ? this.player
+ : this.remotePlayers.get(sessionId)?.sprite;
+ if (!anchor) {
+ bubble.container.setVisible(false);
+ continue;
+ }
+ bubble.container
+ .setVisible(true)
+ .setPosition(anchor.x, anchor.y - 30)
+ .setDepth(anchor.depth + 1_000);
+ }
}
private avatarIndexForSession(sessionId: string) {
diff --git a/apps/web/src/world/integration.test.ts b/apps/web/src/world/integration.test.ts
index dae9a5b..ecdc9c3 100644
--- a/apps/web/src/world/integration.test.ts
+++ b/apps/web/src/world/integration.test.ts
@@ -7,7 +7,7 @@ const { Client } = await import("@colyseus/sdk");
type TestRoom = Room;
const worldUrl = process.env.VITE_WORLD_WS || "ws://127.0.0.1:2567";
-const worldHttpUrl = worldUrl.replace(/^ws/, "http");
+const worldHttpUrl = process.env.VITE_API_BASE_URL || worldUrl.replace(/^ws/, "http");
const webUrl = process.env.VITE_WEB_URL || "http://localhost:5173";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/apps/world-server/Dockerfile b/apps/world-server/Dockerfile
new file mode 100644
index 0000000..573e57b
--- /dev/null
+++ b/apps/world-server/Dockerfile
@@ -0,0 +1,29 @@
+FROM node:24-bookworm-slim
+
+ENV NODE_ENV=production \
+ PORT=2567
+
+WORKDIR /app
+
+RUN npm install --global pnpm@10.14.0
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
+COPY apps/web/package.json apps/web/package.json
+COPY apps/world-server/package.json apps/world-server/package.json
+COPY packages/shared/package.json packages/shared/package.json
+
+RUN pnpm install --frozen-lockfile \
+ && touch .env
+
+COPY apps/world-server/src apps/world-server/src
+COPY packages/shared/src packages/shared/src
+COPY maps maps
+
+USER node
+
+EXPOSE 2567
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD node -e 'fetch("http://127.0.0.1:2567/health").then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))'
+
+CMD ["pnpm", "--filter", "@outcry/world-server", "start"]
diff --git a/apps/world-server/src/chain/match-membership.test.ts b/apps/world-server/src/chain/match-membership.test.ts
index 04a3580..5e0844a 100644
--- a/apps/world-server/src/chain/match-membership.test.ts
+++ b/apps/world-server/src/chain/match-membership.test.ts
@@ -23,12 +23,22 @@ assert.equal(isJoinableMatchAccount({ owner: programId, data: currentData }, pro
currentData[80] = 1;
assert.equal(isJoinableMatchAccount({ owner: programId, data: currentData }, programId), true);
const wallet = Keypair.generate().publicKey;
+legacyData[82] = 1;
wallet.toBuffer().copy(legacyData, 83);
wallet.toBuffer().copy(legacyData, 211);
assert.equal(isMatchMemberAtSeat(legacyData, wallet, 0), true);
+legacyData.fill(0, 83, 211);
+wallet.toBuffer().copy(legacyData, 83);
+wallet.toBuffer().copy(legacyData, 211 + 2 * 32);
+assert.equal(isMatchMemberAtSeat(legacyData, wallet, 2), true);
+currentData[82] = 1;
wallet.toBuffer().copy(currentData, 84);
wallet.toBuffer().copy(currentData, 212);
assert.equal(isMatchMemberAtSeat(currentData, wallet, 0), true);
+currentData.fill(0, 84, 212);
+wallet.toBuffer().copy(currentData, 84);
+wallet.toBuffer().copy(currentData, 212 + 2 * 32);
+assert.equal(isMatchMemberAtSeat(currentData, wallet, 2), true);
assert.equal(isMatchMemberAtSeat(legacyData, Keypair.generate().publicKey, 0), false);
assert.equal(isValidMatchAccount({ owner: programId, data: Buffer.alloc(371) }, programId), false);
const wrongDiscriminator = Buffer.from(validData);
diff --git a/apps/world-server/src/chain/match-membership.ts b/apps/world-server/src/chain/match-membership.ts
index e47c760..cc0dcc8 100644
--- a/apps/world-server/src/chain/match-membership.ts
+++ b/apps/world-server/src/chain/match-membership.ts
@@ -19,8 +19,12 @@ const MATCH_CURRENT_SEATS_OFFSET = 212;
const MAX_PLAYERS = 4;
const PIT_ACTIVE_MATCH_OFFSET = 73;
const PIT_SEED = Buffer.from("pit");
-const MEMBERSHIP_READ_ATTEMPTS = 4;
-const MEMBERSHIP_READ_DELAY_MS = 250;
+const ACTIVE_MATCH_READ_ATTEMPTS = 3;
+const ACTIVE_MATCH_READ_DELAY_MS = 250;
+const MEMBERSHIP_READ_ATTEMPTS = 6;
+const MEMBERSHIP_READ_DELAY_MS = 500;
+
+const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
export type MatchMembershipRequest = {
matchAddress: string;
@@ -58,9 +62,16 @@ export function isMatchMemberAtSeat(data: Buffer, walletAddress: PublicKey, seat
if (seatIndex < 0 || seatIndex >= MAX_PLAYERS || !MATCH_ACCOUNT_BYTES.has(data.length)) return false;
const playersOffset = data.length === 372 ? MATCH_LEGACY_PLAYERS_OFFSET : MATCH_CURRENT_PLAYERS_OFFSET;
const seatsOffset = data.length === 372 ? MATCH_LEGACY_SEATS_OFFSET : MATCH_CURRENT_SEATS_OFFSET;
- const player = new PublicKey(data.subarray(playersOffset + seatIndex * 32, playersOffset + (seatIndex + 1) * 32));
const seat = new PublicKey(data.subarray(seatsOffset + seatIndex * 32, seatsOffset + (seatIndex + 1) * 32));
- return player.equals(walletAddress) && seat.equals(walletAddress);
+ if (!seat.equals(walletAddress)) return false;
+
+ // `players` is append-only by join order; `seats` is indexed by the
+ // requested seat. They are intentionally different indexes.
+ const playerCount = data.readUInt8(MATCH_PLAYER_COUNT_OFFSET);
+ if (playerCount === 0 || playerCount > MAX_PLAYERS) return false;
+ return Array.from({ length: playerCount }, (_, index) =>
+ new PublicKey(data.subarray(playersOffset + index * 32, playersOffset + (index + 1) * 32)),
+ ).some((player) => player.equals(walletAddress));
}
export class SolanaMatchMembershipReader implements MatchMembershipReader {
@@ -88,15 +99,18 @@ export class SolanaMatchMembershipReader implements MatchMembershipReader {
const pitIdBytes = Buffer.alloc(32);
encoded.copy(pitIdBytes);
const [pitAddress] = PublicKey.findProgramAddressSync([PIT_SEED, pitIdBytes], this.programId);
- try {
- const account = await this.connection.getAccountInfo(pitAddress, "confirmed");
- if (!account || !account.owner.equals(this.programId) || account.data.length < PIT_ACTIVE_MATCH_OFFSET + 32) return undefined;
- const active = new PublicKey(account.data.subarray(PIT_ACTIVE_MATCH_OFFSET, PIT_ACTIVE_MATCH_OFFSET + 32));
- if (active.equals(PublicKey.default)) return undefined;
- return active.toBase58();
- } catch {
- return undefined;
+ for (let attempt = 0; attempt < ACTIVE_MATCH_READ_ATTEMPTS; attempt += 1) {
+ try {
+ const account = await this.connection.getAccountInfo(pitAddress, "confirmed");
+ if (!account || !account.owner.equals(this.programId) || account.data.length < PIT_ACTIVE_MATCH_OFFSET + 32) return undefined;
+ const active = new PublicKey(account.data.subarray(PIT_ACTIVE_MATCH_OFFSET, PIT_ACTIVE_MATCH_OFFSET + 32));
+ if (active.equals(PublicKey.default)) return undefined;
+ return active.toBase58();
+ } catch {
+ if (attempt + 1 < ACTIVE_MATCH_READ_ATTEMPTS) await delay(ACTIVE_MATCH_READ_DELAY_MS);
+ }
}
+ return undefined;
}
async isConfirmed(input: MatchMembershipRequest) {
@@ -120,7 +134,7 @@ export class SolanaMatchMembershipReader implements MatchMembershipReader {
if ((status !== MATCH_WAITING_STATUS && status !== MATCH_STARTED_STATUS && status !== MATCH_FINISHED_STATUS) || playerCount === 0 || playerCount > MAX_PLAYERS || capacity === 0 || capacity > MAX_PLAYERS || capacity < playerCount) return false;
if (isMatchMemberAtSeat(account.data, walletAddress, input.seatIndex)) return true;
}
- if (attempt + 1 < MEMBERSHIP_READ_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, MEMBERSHIP_READ_DELAY_MS));
+ if (attempt + 1 < MEMBERSHIP_READ_ATTEMPTS) await delay(MEMBERSHIP_READ_DELAY_MS);
}
return false;
}
diff --git a/apps/world-server/src/index.ts b/apps/world-server/src/index.ts
index a9dd5a2..3059940 100644
--- a/apps/world-server/src/index.ts
+++ b/apps/world-server/src/index.ts
@@ -8,16 +8,34 @@ import { WorldRoom } from "./world/WorldRoom";
const env = readServerEnv();
const port = Number(process.env.PORT ?? 2567);
+const frontendBaseUrl = env.FRONTEND_BASE_URL.replace(/\/+$/, "");
+const corsAllowedHeaders =
+ "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Outcry-Match-Id, X-Outcry-Session-Id";
+const corsAllowedMethods = "GET,POST,OPTIONS";
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be an integer between 1 and 65535");
}
-matchMaker.controller.DEFAULT_CORS_HEADERS["Access-Control-Allow-Headers"] =
- "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Outcry-Match-Id, X-Outcry-Session-Id";
+Object.assign(matchMaker.controller.DEFAULT_CORS_HEADERS, {
+ "Access-Control-Allow-Origin": frontendBaseUrl,
+ "Access-Control-Allow-Headers": corsAllowedHeaders,
+ "Access-Control-Allow-Methods": corsAllowedMethods,
+});
const gameServer = new Server({
express: (app) => {
+ app.use((request: Request, response: Response, next) => {
+ response.setHeader("Access-Control-Allow-Origin", frontendBaseUrl);
+ response.setHeader("Access-Control-Allow-Headers", corsAllowedHeaders);
+ response.setHeader("Access-Control-Allow-Methods", corsAllowedMethods);
+ response.setHeader("Vary", "Origin");
+ if (request.method === "OPTIONS") {
+ response.sendStatus(204);
+ return;
+ }
+ next();
+ });
app.use(express.json());
app.get(
"/health",
diff --git a/apps/world-server/src/world/WorldRoom.test.ts b/apps/world-server/src/world/WorldRoom.test.ts
index d5cee2d..efca057 100644
--- a/apps/world-server/src/world/WorldRoom.test.ts
+++ b/apps/world-server/src/world/WorldRoom.test.ts
@@ -8,6 +8,11 @@ assert.match(source, /chain_match_unavailable/);
assert.match(source, /rpcUrl: env\.OUTCRY_BASE_RPC/);
assert.match(source, /refreshActiveMatch/);
assert.match(source, /if \(seatIndex === undefined\) return;/);
-assert.match(source, /canRebindReservedSeat/);
+assert.match(source, /canRebindPendingSeat/);
+assert.match(source, /this\.reservePlayer\(player, result\.seat\)/);
+assert.match(source, /player\.mode = "RESERVING"/);
+assert.match(source, /RECONNECTION_GRACE_SECONDS = 30/);
+assert.match(source, /onMessage\("chat"/);
+assert.match(source, /this\.broadcast\("chat"/);
console.log("world room: first seat can trigger lazy Match setup when the configured address is present");
diff --git a/apps/world-server/src/world/WorldRoom.ts b/apps/world-server/src/world/WorldRoom.ts
index c726296..ca1f1bf 100644
--- a/apps/world-server/src/world/WorldRoom.ts
+++ b/apps/world-server/src/world/WorldRoom.ts
@@ -1,5 +1,5 @@
import { Room, type Client } from "colyseus";
-import { type MovementInput } from "@outcry/shared/domain";
+import { MAX_CHAT_MESSAGE_LENGTH, parseChatMessage, type MovementInput } from "@outcry/shared/domain";
import { readServerEnv } from "@outcry/shared/env";
import { createMatchMembershipReader, type MatchMembershipReader } from "../chain/match-membership";
import { loadWorldGeometry, type WorldGeometry } from "./geometry";
@@ -19,6 +19,8 @@ const TICK_DT_MS = 1000 / TICK_RATE_HZ;
const MAX_INPUTS_PER_TICK = 8;
const MAX_PENDING_INPUTS = 32;
const ACTIVE_MATCH_REFRESH_MS = 5_000;
+const RECONNECTION_GRACE_SECONDS = 30;
+const CHAT_COOLDOWN_MS = 700;
export class WorldRoom extends Room<{ state: WorldState }> {
// ponytail: process-local presence count; use shared storage when the server is horizontally scaled.
@@ -47,6 +49,7 @@ export class WorldRoom extends Room<{ state: WorldState }> {
private activeMatchReadAt = 0;
private activeMatchRefresh?: Promise;
private readonly pendingInputs = new Map();
+ private readonly lastChatAt = new Map();
async onCreate(options: { worldId?: string } = {}) {
const worldId = options.worldId ?? "wall-street";
@@ -66,6 +69,7 @@ export class WorldRoom extends Room<{ state: WorldState }> {
this.setState(new WorldState());
this.initializePitState();
this.onMessage("input", (client, payload) => this.enqueueInput(client, payload));
+ this.onMessage("chat", (client, payload) => this.handleChat(client, payload));
this.onMessage("interact", (client, payload) => void this.interact(client, payload));
this.onMessage("beginConfirm", (client) => this.beginConfirmation(client));
this.onMessage("confirmSeat", (client, payload) => void this.confirmSeat(client, payload));
@@ -95,10 +99,12 @@ export class WorldRoom extends Room<{ state: WorldState }> {
const player = this.state.players.get(client.sessionId);
const previousMode = player?.mode;
if (player) player.mode = "RECONNECTING";
- this.allowReconnection(client, 5)
+ this.allowReconnection(client, RECONNECTION_GRACE_SECONDS)
.then(() => {
const restored = this.state.players.get(client.sessionId);
- if (restored) restored.mode = previousMode === "SEATED" ? "SEATED" : "WALKING";
+ if (restored) restored.mode = previousMode === "SEATED" || previousMode === "RESERVING"
+ ? previousMode
+ : "WALKING";
})
.catch(() => this.removePlayer(client.sessionId));
}
@@ -115,6 +121,19 @@ export class WorldRoom extends Room<{ state: WorldState }> {
queue.push(input);
}
+ private handleChat(client: Client, payload: unknown) {
+ const message = parseChatMessage(payload);
+ if (!message || !this.state.players.has(client.sessionId)) return;
+ const now = Date.now();
+ const lastSentAt = this.lastChatAt.get(client.sessionId) ?? 0;
+ if (now - lastSentAt < CHAT_COOLDOWN_MS) return;
+ this.lastChatAt.set(client.sessionId, now);
+ this.broadcast("chat", {
+ sessionId: client.sessionId,
+ text: message.text.slice(0, MAX_CHAT_MESSAGE_LENGTH),
+ });
+ }
+
private simulate() {
this.expireLeases();
for (const [sessionId, queue] of this.pendingInputs) {
@@ -139,6 +158,7 @@ export class WorldRoom extends Room<{ state: WorldState }> {
}
this.state.players.delete(sessionId);
this.pendingInputs.delete(sessionId);
+ this.lastChatAt.delete(sessionId);
WorldRoom.activeSessions.delete(sessionId);
WorldRoom.seatedSessions.delete(sessionId);
}
@@ -207,7 +227,7 @@ export class WorldRoom extends Room<{ state: WorldState }> {
player.x,
player.y,
);
- if (result.accepted) this.seatPlayer(client.sessionId, player, result.seat);
+ if (result.accepted) this.reservePlayer(player, result.seat);
this.sendSeatResult(client, result);
}
@@ -248,7 +268,9 @@ export class WorldRoom extends Room<{ state: WorldState }> {
return;
}
const matchChanged = confirmation.matchAddress !== this.activeMatchAddress;
- const activeOnchainMatch = matchChanged && seat.pitId === "wall-street-01"
+ // Always validate the pit PDA. The cached address can lag after the host
+ // releases a match and creates the next one.
+ const activeOnchainMatch = seat.pitId === "wall-street-01"
? await this.membershipReader.isActiveMatch(seat.pitId, confirmation.matchAddress)
: true;
if (seat.pitId !== "wall-street-01" || !activeOnchainMatch) {
@@ -288,21 +310,31 @@ export class WorldRoom extends Room<{ state: WorldState }> {
const identity = parseSeatReconciliation(payload);
const player = this.state.players.get(client.sessionId);
const currentSeat = this.seating.getForSession(client.sessionId);
- const canRebindReservedSeat = Boolean(currentSeat && currentSeat.status === "RESERVED" && player?.mode === "SEATED");
- if (!identity || !player || (player.mode !== "WALKING" && !canRebindReservedSeat)) {
+ const canRebindPendingSeat = Boolean(
+ currentSeat &&
+ (currentSeat.status === "RESERVED" || currentSeat.status === "CONFIRMING") &&
+ player?.mode === "RESERVING",
+ );
+ if (!identity || !player || (player.mode !== "WALKING" && !canRebindPendingSeat)) {
this.sendSeatResult(client, { accepted: false, reason: "invalid_reconciliation" });
return;
}
const pit = this.geometry.pits.find(({ pitId }) => pitId === "wall-street-01");
- if (
- !pit ||
- identity.matchAddress !== this.activeMatchAddress ||
- !this.membershipReader
- ) {
+ if (!pit || !this.membershipReader) {
this.sendSeatResult(client, { accepted: false, reason: "chain_reconciliation_unavailable" });
return;
}
- if (canRebindReservedSeat && currentSeat) {
+ const activeOnchainMatch = await this.membershipReader.isActiveMatch(pit.pitId, identity.matchAddress);
+ if (!activeOnchainMatch) {
+ this.sendSeatResult(client, { accepted: false, reason: "chain_reconciliation_unavailable" });
+ return;
+ }
+ if (identity.matchAddress !== this.activeMatchAddress) {
+ this.activeMatchAddress = identity.matchAddress;
+ const pitState = this.state.pits.get(pit.pitId);
+ if (pitState) pitState.activeMatchId = identity.matchAddress;
+ }
+ if (canRebindPendingSeat && currentSeat) {
const releasedSeats = this.seating.releaseSession(client.sessionId);
for (const released of releasedSeats) this.syncSeat(this.seating.get(released.pitId, released.seatIndex));
this.clearSeatPlayer(player, currentSeat, client.sessionId);
@@ -343,14 +375,24 @@ export class WorldRoom extends Room<{ state: WorldState }> {
}
private seatPlayer(sessionId: string, player: PlayerState, seat: SeatLease) {
+ this.positionPlayerAtSeat(player, seat);
+ player.mode = "SEATED";
+ WorldRoom.seatedSessions.set(sessionId, { pitId: seat.pitId, seatIndex: seat.seatIndex });
+ this.syncSeat(seat);
+ }
+
+ private reservePlayer(player: PlayerState, seat: SeatLease) {
+ this.positionPlayerAtSeat(player, seat);
+ player.mode = "RESERVING";
+ this.syncSeat(seat);
+ }
+
+ private positionPlayerAtSeat(player: PlayerState, seat: SeatLease) {
player.x = seat.x;
player.y = seat.y;
player.facing = seat.facing;
- player.mode = "SEATED";
player.pitId = seat.pitId;
player.seatIndex = seat.seatIndex;
- WorldRoom.seatedSessions.set(sessionId, { pitId: seat.pitId, seatIndex: seat.seatIndex });
- this.syncSeat(seat);
}
private clearSeatPlayer(player: PlayerState, seat?: SeatLease, sessionId?: string) {
diff --git a/apps/world-server/src/world/chat.test.ts b/apps/world-server/src/world/chat.test.ts
new file mode 100644
index 0000000..f217686
--- /dev/null
+++ b/apps/world-server/src/world/chat.test.ts
@@ -0,0 +1,10 @@
+import assert from "node:assert/strict";
+import { MAX_CHAT_MESSAGE_LENGTH, parseChatMessage } from "@outcry/shared/domain";
+
+assert.deepEqual(parseChatMessage({ text: " hello floor " }), { text: "hello floor" });
+assert.equal(parseChatMessage({ text: "" }), null);
+assert.equal(parseChatMessage({ text: "x".repeat(MAX_CHAT_MESSAGE_LENGTH + 1) }), null);
+assert.equal(parseChatMessage({ text: "hello", extra: true }), null);
+assert.equal(parseChatMessage({ text: 42 }), null);
+
+console.log("chat: payload validation and whitespace normalization pass");
diff --git a/apps/world-server/src/world/seat-leasing.test.ts b/apps/world-server/src/world/seat-leasing.test.ts
index e44615d..9436db0 100644
--- a/apps/world-server/src/world/seat-leasing.test.ts
+++ b/apps/world-server/src/world/seat-leasing.test.ts
@@ -5,6 +5,7 @@ import {
parseSeatReconciliation,
parseSeatRequest,
SeatLeaseManager,
+ SEAT_LEASE_TTL_MS,
} from "./seat-leasing";
const geometry = await loadWorldGeometry();
@@ -54,15 +55,20 @@ assert.equal(
for (let cycle = 0; cycle < 100; cycle += 1) {
const lease = manager.reserve(`expiry-${cycle}`, pit.pitId, seat.seatIndex, seat.x, seat.y);
assert.equal(lease.accepted, true);
- now += 10_000;
+ now += SEAT_LEASE_TTL_MS + 1;
assert.equal(manager.expire().length, 1);
assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "FREE");
}
const confirmation = manager.reserve("confirmed", pit.pitId, seat.seatIndex, seat.x, seat.y);
assert.equal(confirmation.accepted, true);
+now += 1_000;
assert.equal(manager.beginConfirmation("confirmed").accepted, true);
assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "CONFIRMING");
+assert.equal(manager.get(pit.pitId, seat.seatIndex)?.leaseExpiresAt, now + SEAT_LEASE_TTL_MS);
+now += 1_000;
+assert.equal(manager.beginConfirmation("confirmed").accepted, true, "wallet retries extend a pending confirmation");
+assert.equal(manager.get(pit.pitId, seat.seatIndex)?.leaseExpiresAt, now + SEAT_LEASE_TTL_MS);
assert.equal(manager.confirm("confirmed").accepted, true);
assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "CONFIRMED");
now += 100_000;
diff --git a/apps/world-server/src/world/seat-leasing.ts b/apps/world-server/src/world/seat-leasing.ts
index 7766feb..67cf4ad 100644
--- a/apps/world-server/src/world/seat-leasing.ts
+++ b/apps/world-server/src/world/seat-leasing.ts
@@ -1,7 +1,10 @@
import type { SeatStatus } from "@outcry/shared/domain";
import type { PitDefinition, SeatDefinition } from "./geometry";
-export const SEAT_LEASE_TTL_MS = 10_000;
+// Wallet confirmation can take longer than a normal interaction request.
+// Keep the reservation long enough for one wallet attempt; failed attempts
+// still release immediately through WorldRoom.confirmSeat.
+export const SEAT_LEASE_TTL_MS = 60_000;
export type SeatLease = {
pitId: string;
@@ -122,8 +125,11 @@ export class SeatLeaseManager {
beginConfirmation(sessionId: string): SeatActionResult {
const seat = this.seatFor(sessionId);
if (!seat) return { accepted: false, reason: "no_active_seat" };
- if (seat.status !== "RESERVED") return { accepted: false, reason: "invalid_confirmation_state" };
+ if (seat.status !== "RESERVED" && seat.status !== "CONFIRMING") {
+ return { accepted: false, reason: "invalid_confirmation_state" };
+ }
seat.status = "CONFIRMING";
+ seat.leaseExpiresAt = this.now() + this.ttlMs;
return { accepted: true, action: "confirming", seat: { ...seat } };
}
diff --git a/apps/world-server/src/world/world.test.ts b/apps/world-server/src/world/world.test.ts
index ce1783d..45b9402 100644
--- a/apps/world-server/src/world/world.test.ts
+++ b/apps/world-server/src/world/world.test.ts
@@ -1,6 +1,7 @@
import "./simulation.test";
import "./seat-leasing.test";
import "./WorldRoom.test";
+import "./chat.test";
import "../chain/match-membership.test";
import "../media/livekit.test";
diff --git a/docs/AUTHORITY.md b/docs/AUTHORITY.md
new file mode 100644
index 0000000..50e5f09
--- /dev/null
+++ b/docs/AUTHORITY.md
@@ -0,0 +1,20 @@
+# OUTCRY authority contract
+
+This is the Phase 0 boundary freeze from the technical Bible.
+
+| Concern | Single authority |
+|---|---|
+| Tiled map geometry | Static map assets |
+| World presence, movement, seats, leases | Colyseus world server |
+| Video/audio transport | LiveKit |
+| Speech transcription and parsing | STT/client draft only |
+| Private RFQ, quotes, inventory, fast game resolution | MagicBlock PER |
+| Durable match identity, result, escrow, settlement | Solana L1 |
+
+Hard boundaries:
+
+- Colyseus never selects an economic winner or settles funds.
+- The frontend sends requests; it is never canonical.
+- STT cannot sign or submit transactions.
+- Movement never goes onchain.
+- Private game actions fail closed when verified PER access is unavailable.
diff --git a/maps/wall-street/assets/background.png b/maps/wall-street/assets/background.png
new file mode 100644
index 0000000..7bbeb2d
Binary files /dev/null and b/maps/wall-street/assets/background.png differ
diff --git a/maps/wall-street/world.tmj b/maps/wall-street/world.tmj
index 4865267..9d1ef52 100644
--- a/maps/wall-street/world.tmj
+++ b/maps/wall-street/world.tmj
@@ -989,16 +989,6 @@
"y": 0,
"width": 32,
"height": 480
- },
- {
- "id": 5,
- "name": "central_column",
- "type": "Collision",
- "class": "Collision",
- "x": 304,
- "y": 64,
- "width": 32,
- "height": 96
}
]
},
diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts
index 39309b6..16f39fd 100644
--- a/packages/shared/src/domain.ts
+++ b/packages/shared/src/domain.ts
@@ -4,7 +4,7 @@ export type MatchId = string;
export type SeatIndex = 0 | 1 | 2 | 3;
export type AppMode = "WORLD" | "SEAT_CONFIRM" | "PIT";
-export type PlayerMode = "WALKING" | "RECONNECTING" | "SEATED";
+export type PlayerMode = "WALKING" | "RECONNECTING" | "RESERVING" | "SEATED";
export type Facing = "down" | "left" | "right" | "up";
export type SeatStatus = "FREE" | "RESERVED" | "CONFIRMING" | "CONFIRMED" | "IN_MATCH";
@@ -17,6 +17,21 @@ export type MovementInput = {
dtMs: number;
};
+export const MAX_CHAT_MESSAGE_LENGTH = 80;
+
+export type ChatMessage = {
+ text: string;
+};
+
+export function parseChatMessage(payload: unknown): ChatMessage | null {
+ if (!payload || typeof payload !== "object") return null;
+ const value = payload as Record;
+ if (Object.keys(value).length !== 1 || typeof value.text !== "string") return null;
+ const text = value.text.replace(/\s+/g, " ").trim();
+ if (text.length === 0 || text.length > MAX_CHAT_MESSAGE_LENGTH) return null;
+ return { text };
+}
+
export const MAX_WORLD_SPEED = 150;
export const MAX_INPUT_DT_MS = 50;
diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts
index 4b350f1..1fcef07 100644
--- a/packages/shared/src/env.ts
+++ b/packages/shared/src/env.ts
@@ -19,6 +19,7 @@ const publicEnvSchema = z.object({
});
const serverEnvSchema = publicEnvSchema.extend({
+ FRONTEND_BASE_URL: z.string().url().default("http://localhost:5173"),
LIVEKIT_URL: optionalUrl,
LIVEKIT_API_KEY: optionalString,
LIVEKIT_API_SECRET: optionalString,
diff --git a/programs/outcry/src/lib.rs b/programs/outcry/src/lib.rs
index f8229ac..22fe1a3 100644
--- a/programs/outcry/src/lib.rs
+++ b/programs/outcry/src/lib.rs
@@ -177,7 +177,7 @@ pub mod outcry {
let round = &mut ctx.accounts.round;
round.match_key = match_state.key();
round.round = match_state.current_round;
- round.taker = match_state.players[match_state.current_round as usize % match_state.player_count as usize];
+ round.taker = match_state.players[match_taker_index(match_state)?];
round.side = SIDE_BUY;
round.quantity_lots = 0;
round.opened_at = 0;
@@ -1026,7 +1026,7 @@ fn open_rfq_state(
ORACLE_MAX_AGE_SECONDS,
))?;
- let taker_index = (match_state.current_round as usize) % match_state.player_count as usize;
+ let taker_index = match_taker_index(match_state)?;
require_keys_eq!(taker, match_state.players[taker_index], ErrorCode::NotCurrentTaker);
round.match_key = match_key;
round.round = match_state.current_round;
@@ -1285,7 +1285,7 @@ pub struct JoinMatch<'info> {
#[derive(Accounts)]
pub struct StartMatch<'info> {
- #[account(mut, constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)]
+ #[account(mut, has_one = authority)]
pub match_state: Account<'info, Match>,
pub authority: Signer<'info>,
}
@@ -1550,7 +1550,7 @@ pub struct NextRoundSession<'info> {
#[derive(Accounts)]
pub struct FinalizeScores<'info> {
- #[account(constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)]
+ #[account(has_one = authority)]
pub match_state: Account<'info, Match>,
#[account(mut, address = match_state.result)]
pub result: Account<'info, MatchResult>,
@@ -1588,7 +1588,7 @@ pub struct InitializeEscrow<'info> {
#[derive(Accounts)]
pub struct InitializeMatchResult<'info> {
- #[account(mut, constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)]
+ #[account(mut, has_one = authority)]
pub match_state: Account<'info, Match>,
#[account(
init,
@@ -1885,12 +1885,11 @@ pub struct UndelegateRound<'info> {
pub struct UndelegateMatch<'info> {
#[account(mut)]
pub payer: Signer<'info>,
- #[account(mut)]
+ #[account(mut, has_one = authority)]
pub match_state: Account<'info, Match>,
/// CHECK: MagicBlock validates the delegated payer fee-vault PDA.
#[account(mut)]
pub magic_fee_vault: UncheckedAccount<'info>,
- #[account(constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)]
pub authority: Signer<'info>,
}
@@ -1986,7 +1985,8 @@ impl Match {
}
fn start(&mut self, host: Pubkey, round_count: u8) -> Result<()> {
- require_keys_eq!(self.players[0], host, ErrorCode::NotMatchHost);
+ require_keys_eq!(self.authority, host, ErrorCode::NotMatchHost);
+ require!(self.players.contains(&host), ErrorCode::NotAMatchPlayer);
require!(self.status == MATCH_WAITING, ErrorCode::MatchAlreadyStarted);
require!(self.player_count >= MIN_PLAYERS_TO_START, ErrorCode::NotEnoughPlayers);
require!(round_count > 0 && round_count <= MAX_ROUNDS, ErrorCode::InvalidRoundCount);
@@ -1996,13 +1996,21 @@ impl Match {
}
}
-fn legacy_host_for_migration(
- stored_authority: Pubkey,
- first_player: Pubkey,
- caller: Pubkey,
-) -> Result {
- require!(stored_authority == caller || first_player == caller, ErrorCode::NotMatchHost);
- Ok(if first_player == Pubkey::default() { stored_authority } else { first_player })
+fn match_taker_index(match_state: &Match) -> Result {
+ let player_count = match_state.player_count as usize;
+ require!(player_count > 0 && player_count <= MAX_PLAYERS, ErrorCode::NotEnoughPlayers);
+ // Older matches may have been started before authority was required to be seated;
+ // preserve their existing player-zero rotation while new matches anchor on the host.
+ let host_index = match_state.players[..player_count]
+ .iter()
+ .position(|player| *player == match_state.authority)
+ .unwrap_or(0);
+ Ok((host_index + match_state.current_round as usize) % player_count)
+}
+
+fn legacy_host_for_migration(stored_authority: Pubkey, _first_player: Pubkey, caller: Pubkey) -> Result {
+ require_keys_eq!(stored_authority, caller, ErrorCode::NotMatchHost);
+ Ok(stored_authority)
}
fn match_can_be_released(status: u8, current_round: u8, result: Pubkey) -> bool {
@@ -2308,8 +2316,9 @@ mod tests {
fn start_allows_two_players_and_is_once_only() {
let mut match_state = empty_match(MAX_PLAYERS as u8);
let host = Pubkey::new_unique();
- assert!(match_state.join(host, 0).is_ok());
- assert!(match_state.join(Pubkey::new_unique(), 1).is_ok());
+ match_state.authority = host;
+ assert!(match_state.join(Pubkey::new_unique(), 0).is_ok());
+ assert!(match_state.join(host, 1).is_ok());
assert!(match_state.start(Pubkey::new_unique(), DEFAULT_ROUND_COUNT).is_err());
assert!(match_state.start(host, 0).is_err());
assert!(match_state.start(host, MAX_ROUNDS + 1).is_err());
@@ -2319,6 +2328,19 @@ mod tests {
assert_eq!(match_state.round_count, DEFAULT_ROUND_COUNT);
}
+ #[test]
+ fn host_is_first_taker_and_rotation_starts_from_host() {
+ let mut match_state = empty_match(MAX_PLAYERS as u8);
+ let first_joiner = Pubkey::new_unique();
+ let host = match_state.authority;
+ assert!(match_state.join(first_joiner, 0).is_ok());
+ assert!(match_state.join(host, 1).is_ok());
+
+ assert_eq!(match_taker_index(&match_state).unwrap(), 1);
+ match_state.current_round = 1;
+ assert_eq!(match_taker_index(&match_state).unwrap(), 0);
+ }
+
#[test]
fn host_can_skip_only_an_empty_expired_open_round() {
let mut match_state = empty_match(2);
@@ -2349,15 +2371,16 @@ mod tests {
}
#[test]
- fn legacy_migration_promotes_first_joined_player() {
+ fn legacy_migration_preserves_stored_authority() {
let stored_authority = Pubkey::new_unique();
let first_player = Pubkey::new_unique();
let second_player = Pubkey::new_unique();
assert_eq!(
- legacy_host_for_migration(stored_authority, first_player, first_player).unwrap(),
- first_player
+ legacy_host_for_migration(stored_authority, first_player, stored_authority).unwrap(),
+ stored_authority
);
+ assert!(legacy_host_for_migration(stored_authority, first_player, first_player).is_err());
assert!(legacy_host_for_migration(stored_authority, first_player, second_player).is_err());
}