Skip to content

Commit a8a282f

Browse files
WIP hydra doom
1 parent 59ba6e3 commit a8a282f

File tree

13 files changed

+546
-70
lines changed

13 files changed

+546
-70
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"devDependencies": {
2929
"@eslint/js": "^9.9.0",
3030
"@types/lodash": "^4.17.7",
31+
"@types/node": "^22.7.7",
3132
"@types/react": "^18.3.3",
3233
"@types/react-dom": "^18.3.0",
3334
"@vitejs/plugin-react": "^4.3.1",

src/components/DoomCanvas/DoomCanvas.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import React, { useEffect, useRef } from "react";
22
import { EmscriptenModule } from "../../types";
33
import { useAppContext } from "../../context/useAppContext";
4+
import { HydraMultiplayer } from "../../utils/hydra-multiplayer";
5+
import useKeys from "../../hooks/useKeys";
46

57
const DoomCanvas: React.FC = () => {
68
const canvasRef = useRef<HTMLCanvasElement>(null);
79
const isEffectRan = useRef(false);
810
const { gameData } = useAppContext();
11+
const keys = useKeys();
912

1013
console.log("gameData", gameData);
1114

1215
useEffect(() => {
1316
// Prevent effect from running twice
17+
if (!keys.address) return;
1418
if (isEffectRan.current) return;
1519
isEffectRan.current = true;
1620

@@ -28,6 +32,12 @@ const DoomCanvas: React.FC = () => {
2832

2933
canvas.addEventListener("webglcontextlost", handleContextLost, false);
3034

35+
debugger;
36+
window.HydraMultiplayer = new HydraMultiplayer(
37+
keys,
38+
"http://localhost:4001",
39+
);
40+
3141
// Setup configuration for doom-wasm
3242
const Module: EmscriptenModule = {
3343
noInitialRun: true,
@@ -52,7 +62,7 @@ const DoomCanvas: React.FC = () => {
5262
console.log("setStatus:", text);
5363
},
5464
onRuntimeInitialized: function () {
55-
window.callMain([
65+
const args = [
5666
"-iwad",
5767
"freedoom2.wad",
5868
"-file",
@@ -62,7 +72,19 @@ const DoomCanvas: React.FC = () => {
6272
"-nomusic",
6373
"-config",
6474
"default.cfg",
65-
]);
75+
];
76+
if (gameData.code) {
77+
args.push("-connect");
78+
args.push("1");
79+
} else {
80+
args.push("-server");
81+
args.push("-deathmatch");
82+
}
83+
if (gameData.petName) {
84+
args.push("-pet");
85+
args.push(gameData.petName);
86+
}
87+
window.callMain(args);
6688
},
6789
};
6890

@@ -78,7 +100,7 @@ const DoomCanvas: React.FC = () => {
78100
canvas.removeEventListener("webglcontextlost", handleContextLost);
79101
document.body.removeChild(script);
80102
};
81-
}, []);
103+
}, [keys.address]);
82104

83105
return <canvas id="canvas" ref={canvasRef} className="w-full h-full" />;
84106
};

src/context/GameContextProvider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { useQuery } from "@tanstack/react-query";
33
import { GameContext } from "./useGameContext";
44
import { GameStatistics } from "../types";
55
import { CABINET_KEY, REGION, SERVER_URL } from "../constants";
6-
import useAddress from "../hooks/useAddress";
6+
import useKeys from "../hooks/useKeys";
77
import { useAppContext } from "./useAppContext";
88

99
const GameContextProvider: FC<PropsWithChildren> = ({ children }) => {
1010
const { region } = useAppContext();
11-
const address = useAddress();
11+
const { address } = useKeys();
1212
const newGameQuery = useQuery<GameStatistics>({
1313
queryKey: ["newGame", address, region.value],
1414
queryFn: async () => {

src/hooks/useAddress.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/hooks/useKeys.ts

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,58 @@
11
import { useLocalStorage } from "usehooks-ts";
22
import { HYDRA_DOOM_SESSION_KEY } from "../constants";
33
import { useCallback, useEffect, useState } from "react";
4-
import { Lucid } from "lucid-cardano";
4+
import { Lucid, toHex } from "lucid-cardano";
55
import * as bech32 from "bech32-buffer";
66
import * as ed25519 from "@noble/ed25519";
77
import { blake2b } from "@noble/hashes/blake2b";
8-
import { toHex } from "../utils/numbers";
98

10-
interface Keys {
11-
privateKey?: string;
12-
sessionKey?: string;
13-
sessionPk?: string;
14-
sessionPkh?: string;
9+
export interface Keys {
10+
sessionKeyBech32?: string;
11+
privateKeyBytes?: Uint8Array;
12+
privateKeyHex?: string;
13+
publicKeyBytes?: Uint8Array;
14+
publicKeyHex?: string;
15+
publicKeyHashBytes?: Uint8Array;
16+
publicKeyHashHex?: string;
17+
address?: string;
1518
}
1619

1720
const useKeys = () => {
18-
const [sessionKey, setSessionKey] = useLocalStorage<string>(
21+
const [sessionKeyBech32, setSessionKey] = useLocalStorage<string>(
1922
HYDRA_DOOM_SESSION_KEY,
2023
"",
2124
);
22-
const [keys, setKeys] = useState<Keys>({
23-
privateKey: undefined,
24-
sessionKey: undefined,
25-
sessionPk: undefined,
26-
sessionPkh: undefined,
27-
});
25+
const [keys, setKeys] = useState<Keys>({});
2826

2927
const generateKeys = useCallback(async () => {
30-
let key = sessionKey;
31-
if (!sessionKey) {
28+
const lucid = await Lucid.new(undefined, "Preprod");
29+
30+
let key = sessionKeyBech32;
31+
if (!import.meta.env.PERSISTENT_SESSION || !sessionKeyBech32) {
3232
console.log("Generating new session key");
33-
const lucid = await Lucid.new(undefined, "Preprod");
3433
key = lucid.utils.generatePrivateKey();
3534
setSessionKey(key);
3635
}
3736

38-
const decodedSessionKey = Array.from(bech32.decode(key).data)
39-
.map(toHex)
40-
.join("");
41-
const sessionPk = await ed25519.getPublicKeyAsync(decodedSessionKey);
42-
const sessionPkh = blake2b(sessionPk, { dkLen: 224 / 8 });
37+
const privateKeyBytes = bech32.decode(key).data;
38+
const publicKeyBytes = await ed25519.getPublicKeyAsync(privateKeyBytes);
39+
const publicKeyHashBytes = blake2b(publicKeyBytes, { dkLen: 224 / 8 });
40+
const publicKeyHashHex = toHex(publicKeyHashBytes);
4341
setKeys({
44-
privateKey: decodedSessionKey,
45-
sessionKey,
46-
sessionPk: ed25519.etc.bytesToHex(sessionPk),
47-
sessionPkh: ed25519.etc.bytesToHex(sessionPkh),
42+
sessionKeyBech32,
43+
privateKeyBytes,
44+
privateKeyHex: toHex(privateKeyBytes),
45+
publicKeyBytes,
46+
publicKeyHex: toHex(publicKeyBytes),
47+
publicKeyHashBytes,
48+
publicKeyHashHex,
49+
address: lucid.utils.credentialToAddress({ type: "Key", hash: publicKeyHashHex })
4850
});
49-
}, [sessionKey, setSessionKey]);
51+
}, [sessionKeyBech32, setSessionKey]);
5052

5153
useEffect(() => {
5254
generateKeys();
53-
}, [generateKeys, sessionKey]);
55+
}, [generateKeys, sessionKeyBech32]);
5456

5557
return keys;
5658
};

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { HydraMultiplayer } from "./utils/hydra-multiplayer";
2+
13
export type LeaderboardEntry = [string, number];
24

35
export interface PlayerStats {
@@ -41,21 +43,33 @@ interface FileSystem {
4143
): void;
4244
}
4345

46+
export interface HEAPU8 extends Uint8Array {
47+
[key: number]: number;
48+
}
49+
4450
export interface EmscriptenModule {
4551
canvas?: HTMLCanvasElement;
4652
FS?: FileSystem;
4753
noInitialRun?: boolean;
54+
HEAPU8?: HEAPU8;
4855
onRuntimeInitialized?: () => void;
4956
postRun?: () => void;
5057
preRun?: () => void;
5158
print?: (text: string) => void;
5259
printErr?: (text: string) => void;
5360
setStatus?: (text: string) => void;
61+
62+
_malloc?: (size: number) => number;
63+
_free?: (ptr: number) => void;
64+
65+
_ReceivePacket?: (from: number, buf: number, len: number) => void;
5466
}
5567

5668
declare global {
5769
interface Window {
70+
// Start the doom game with some set of arguments
5871
callMain: (args: string[]) => void;
5972
Module: EmscriptenModule;
73+
HydraMultiplayer: HydraMultiplayer;
6074
}
6175
}

src/utils/hydra-multiplayer.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Constr, Data, fromHex, toHex, TxComplete, TxHash, UTxO } from "lucid-cardano";
2+
import { Hydra } from "./hydra";
3+
4+
import * as ed25519 from "@noble/ed25519";
5+
import { blake2b } from "@noble/hashes/blake2b";
6+
import { sha512 } from "@noble/hashes/sha512";
7+
import { Keys } from "../hooks/useKeys";
8+
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
9+
10+
export class HydraMultiplayer {
11+
keys: Keys;
12+
hydra: Hydra;
13+
myIP: number = 0;
14+
latestUTxO: UTxO | null = null;
15+
packetQueue: Packet[] = [];
16+
17+
constructor(keys: Keys, url: string) {
18+
this.keys = keys;
19+
this.hydra = new Hydra(url, 100);
20+
this.hydra.onTxSeen = this.onTxSeen;
21+
}
22+
23+
public setIP(ip: number) {
24+
this.myIP = ip;
25+
}
26+
27+
public async selectUTxO(): Promise<void> {
28+
if (!!this.latestUTxO) {
29+
return;
30+
}
31+
let utxos = await this.hydra.getUtxos(this.keys.address!);
32+
this.latestUTxO = utxos[0];
33+
}
34+
35+
public async sendPacket(to: number, from: number, data: Uint8Array): Promise<void> {
36+
this.packetQueue.push({ to, from, data });
37+
await this.sendPacketQueue();
38+
}
39+
40+
public async sendPacketQueue(): Promise<void> {
41+
if (this.packetQueue.length == 0) {
42+
return;
43+
}
44+
await this.selectUTxO();
45+
let datum = encodePackets(this.packetQueue);
46+
47+
let [newUTxO, tx] = buildTx(this.latestUTxO!, this.keys, datum);
48+
await this.hydra.submitTx(tx);
49+
this.latestUTxO = newUTxO;
50+
this.packetQueue = [];
51+
}
52+
53+
public onTxSeen(_txId: TxHash, tx: TxComplete): void {
54+
// TODO: tolerate other txs here
55+
const output = tx.txComplete.body().outputs().get(0);
56+
const packetsRaw = output?.datum()?.as_data()?.get().to_bytes();
57+
if (!packetsRaw) {
58+
return;
59+
}
60+
const packets = decodePackets(packetsRaw);
61+
for (const packet of packets) {
62+
if (packet.to == this.myIP) {
63+
let buf = window.Module._malloc!(packet.data.length);
64+
window.Module.HEAPU8!.set(packet.data, buf);
65+
window.Module._ReceivePacket!(packet.from, buf, packet.data.length);
66+
window.Module._free!(buf);
67+
}
68+
}
69+
}
70+
}
71+
72+
interface Packet {
73+
to: number,
74+
from: number,
75+
data: Uint8Array,
76+
}
77+
78+
function encodePackets(packets: Packet[]): string {
79+
return Data.to(
80+
packets.map(
81+
({ to, from, data }) => new Constr(0, [BigInt(to), BigInt(from), toHex(data)])
82+
)
83+
);
84+
}
85+
86+
function decodePackets(raw: Uint8Array): Packet[] {
87+
const packets = Data.from(toHex(raw)) as Constr<Data>[];
88+
return packets.map((packet) => {
89+
let [to, from, data] = packet.fields;
90+
return {
91+
to: Number(to),
92+
from: Number(from),
93+
data: fromHex(data as string),
94+
}
95+
});
96+
}
97+
98+
99+
const buildTx = (
100+
inputUtxo: UTxO,
101+
keys: Keys,
102+
datum: string,
103+
): [UTxO, string] => {
104+
// Hand-roll transaction creation for more performance
105+
const datumLength = datum.length / 2;
106+
let datumLengthHex = datumLength.toString(16);
107+
if (datumLengthHex.length % 2 !== 0) {
108+
datumLengthHex = "0" + datumLengthHex;
109+
}
110+
const lengthLengthTag = 57 + datumLengthHex.length / 2;
111+
console.log(datumLengthHex);
112+
const txBodyByHand =
113+
`a3` + // Prefix
114+
`0081825820${inputUtxo.txHash}0${inputUtxo.outputIndex}` + // One input
115+
`0181a300581d60${keys.publicKeyHashHex!}018200a0028201d818${lengthLengthTag}${datumLengthHex}${datum}` + // Output to users PKH
116+
`0200`; // No fee
117+
118+
const txId = toHex(
119+
blake2b(fromHex(txBodyByHand), { dkLen: 256 / 8 }),
120+
);
121+
const signature = toHex(ed25519.sign(txId, keys.privateKeyBytes!));
122+
123+
const witnessSetByHand = `a10081825820${keys.publicKeyHex!}5840${signature}`; // just signed by the user
124+
const txByHand = `84${txBodyByHand}${witnessSetByHand}f5f6`;
125+
126+
const newUtxo: UTxO = {
127+
txHash: txId,
128+
outputIndex: 0,
129+
address: keys.address!,
130+
assets: { lovelace: 0n },
131+
datumHash: null,
132+
datum: datum,
133+
scriptRef: null,
134+
};
135+
136+
return [newUtxo, txByHand];
137+
};

0 commit comments

Comments
 (0)