diff --git a/.gitignore b/.gitignore index 1b4b8c4..12e3754 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /.env /persist/ -/worklist.txt /node_modules -/build \ No newline at end of file +/build +/todo.txt \ No newline at end of file diff --git a/deno.json b/deno.json index c6baf8d..7274bba 100644 --- a/deno.json +++ b/deno.json @@ -9,6 +9,7 @@ "@oneday/http-status": "jsr:@oneday/http-status@^0.2.0", "@proxnet/undead-logging": "jsr:@proxnet/undead-logging@^1.5.0", "@std/assert": "jsr:@std/assert@1", + "chalk": "npm:chalk@^5.6.2", "sharp": "npm:sharp@^0.34.3", "zod": "npm:zod@^4.0.5" }, diff --git a/deno.lock b/deno.lock index 6673845..8f9183c 100644 --- a/deno.lock +++ b/deno.lock @@ -18,6 +18,7 @@ "jsr:@std/path@^1.1.1": "1.1.2", "npm:@types/node@*": "24.2.0", "npm:chalk@^5.3.0": "5.6.0", + "npm:chalk@^5.6.2": "5.6.2", "npm:sharp@~0.34.3": "0.34.3", "npm:zod@^4.0.5": "4.1.5" }, @@ -53,7 +54,7 @@ "@proxnet/undead-logging@1.5.1": { "integrity": "f858b6357d52c4bc1bbab279200dae86ed573ea45d945a8dfaf2f2cb23c4b649", "dependencies": [ - "npm:chalk" + "npm:chalk@^5.3.0" ] }, "@std/assert@1.0.14": { @@ -240,6 +241,9 @@ "chalk@5.6.0": { "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==" }, + "chalk@5.6.2": { + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + }, "color-convert@2.0.1": { "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": [ @@ -330,6 +334,7 @@ "jsr:@oneday/http-status@0.2", "jsr:@proxnet/undead-logging@^1.5.0", "jsr:@std/assert@1", + "npm:chalk@^5.6.2", "npm:sharp@~0.34.3", "npm:zod@^4.0.5" ] diff --git a/res/consumables.json b/res/consumables.json new file mode 100644 index 0000000..bda440e --- /dev/null +++ b/res/consumables.json @@ -0,0 +1,254 @@ +{ + "m_GameObject": { + "m_FileID": 0, + "m_PathID": 0 + }, + "m_Enabled": 1, + "m_Script": { + "m_FileID": 1, + "m_PathID": 1574 + }, + "m_Name": "ConsumableCollectionRuntimeConfig", + "consumablePrefabData": [ + { + "fileName": "[CameraFilterConsumable_BW]", + "consumableName": "frOMH6WxDEG1fBqC4_83vg" + }, + { + "fileName": "[CameraFilterConsumable_Dawn]", + "consumableName": "m0bVLwWGj0GuIBSb6wCk6Q" + }, + { + "fileName": "[CameraFilterConsumable_Dracula]", + "consumableName": "oG7CdvW7p0-S8sVe9w5vRw" + }, + { + "fileName": "[CameraFilterConsumable_GhostBeard]", + "consumableName": "1c0Djlp090uBDczEobYNQw" + }, + { + "fileName": "[CameraFilterConsumable_GoblinKing]", + "consumableName": "6SyCoJCgo0Wd6qlPlnMOtg" + }, + { + "fileName": "[CameraFilterConsumable_Jumbotron]", + "consumableName": "Il4VmrnjDkqjmjzddqoIEw" + }, + { + "fileName": "[CameraFilterConsumable_Sepia]", + "consumableName": "A5M-yf9tgUihq1uab3v58g" + }, + { + "fileName": "[CameraFilterConsumable_Witch]", + "consumableName": "mL3zCEuy2UWZxAV4V-OsMA" + }, + { + "fileName": "[HeadPotionConsumable_DKMode]", + "consumableName": "Tpxqe_lycUelySRHM8B0Vw" + }, + { + "fileName": "[HandPotionConsumable_Goblin1]", + "consumableName": "-hy0qD-iUk-v4NHxNzanmg" + }, + { + "fileName": "[HandPotionConsumable_Pirate1]", + "consumableName": "YEfbJTnsR0yT_p7e7tb_kQ" + }, + { + "fileName": "[HandPotionConsumable_SciFi1]", + "consumableName": "xHOwjwpXd0GDkvBz2VqieA" + }, + { + "fileName": "[HandPotionConsumable_Goblin2]", + "consumableName": "VQSgL2pTLkWx4B3kwYG7UA" + }, + { + "fileName": "[HandPotionConsumable_Dracula1]", + "consumableName": "lag2tZyB90W04lQ7ol4vMw" + }, + { + "fileName": "[FoodConsumable_Donut_Assorted]", + "consumableName": "ZuvkidodzkuOfGLDnTOFyg" + }, + { + "fileName": "[FoodConsumable_Bubbly]", + "consumableName": "iiGTvhOCHkOTNJhb16Zbyw" + }, + { + "fileName": "[FoodConsumable_CheesePizza]", + "consumableName": "5hIAZ9wg5EyG1cILf4FS2A" + }, + { + "fileName": "[FoodConsumable_Donut_Frosted]", + "consumableName": "mMCGPgK3tki5S_15q2Z81A" + }, + { + "fileName": "[FoodConsumable_Donut_Glazed]", + "consumableName": "7OZ5AE3uuUyqa0P-2W1ptg" + }, + { + "fileName": "[FoodConsumable_HawaiianPizza]", + "consumableName": "_jnjYGBcyEWY5Ub4OezXcA" + }, + { + "fileName": "[FoodConsumable_PepperoniPizza]", + "consumableName": "mq23W-RSP0G8iGNLdrcpUw" + }, + { + "fileName": "[FoodConsumable_Popcorn_Butter]", + "consumableName": "QRx0aSTT9keMFdAJMQHdTg" + }, + { + "fileName": "[FoodConsumable_RedApples]", + "consumableName": "uMHrUPLYFk2rJOW_uop5Aw" + }, + { + "fileName": "[FoodConsumable_RootBeer]", + "consumableName": "JfnVXFmilU6ysv-VbTAe3A" + }, + { + "fileName": "[FoodConsumable_Pretzel_Salted]", + "consumableName": "InQ25wQMGkG_bvuD5rf2Ag" + }, + { + "fileName": "[FoodConsumable_SupremePizza]", + "consumableName": "wUCIKdJSvEmiQHYMyx4X4w" + }, + { + "fileName": "[KOConsumable_BearClaw]", + "consumableName": "ZtZnYBpKkECJlhHmkj4MiA" + }, + { + "fileName": "[KOConsumable_Bees]", + "consumableName": "g3kxdlJv5kO8PuBXveM48w" + }, + { + "fileName": "[KOConsumable_Cola]", + "consumableName": "wx--2TPTdEuGAqLCjs9Qag" + }, + { + "fileName": "[KOConsumable_Fangs]", + "consumableName": "XOZcxx-Klkyhe-MDbTqiwA" + }, + { + "fileName": "[KOConsumable_FryingPan]", + "consumableName": "uGVFydNSokCXFAmXu3aceQ" + }, + { + "fileName": "[KOConsumable_Grenade]", + "consumableName": "EAhk3ZZdXEmH5wRAXXT24Q" + }, + { + "fileName": "[KOConsumable_Ranger]", + "consumableName": "Av-wvjXvvkmNVSz7ZZnTiA" + }, + { + "fileName": "[KOConsumable_Shield]", + "consumableName": "5AJin8T2iEG7BzOPOgx2HA" + }, + { + "fileName": "[KOConsumable_SkullWings]", + "consumableName": "U38Qe6rhEk6mFvArHfYjng" + }, + { + "fileName": "[KOConsumable_StarPower]", + "consumableName": "yvqSbK2czkS2sUCRdrGaEw" + }, + { + "fileName": "[KOConsumable_TireTrack]", + "consumableName": "J1WqFNUWo0OBi4LGKPDHWw" + }, + { + "fileName": "[HairDyePotionConsumable_Orange]", + "consumableName": "xr45B5QC4EyKXThtwZto_A" + }, + { + "fileName": "[HairDyePotionConsumable_Blue]", + "consumableName": "JwJeh15cjkOc9WWaGRibDQ" + }, + { + "fileName": "[HairDyePotionConsumable_DarkRed]", + "consumableName": "3R1bzI35fkChoFFRFbponQ" + }, + { + "fileName": "[HairDyePotionConsumable_Cyan]", + "consumableName": "x9ntSHto50GpNWPwYCLUEg" + }, + { + "fileName": "[HairDyePotionConsumable_PastelTeal]", + "consumableName": "4L3JoaqkAUa1kz98nciPXw" + }, + { + "fileName": "[HairDyePotionConsumable_Green]", + "consumableName": "YaVzcoefhk6zjbNDELLx8A" + }, + { + "fileName": "[HairDyePotionConsumable_DarkPurple]", + "consumableName": "iEtWDLmv7ES87J4NE_TGJQ" + }, + { + "fileName": "[HairDyePotionConsumable_PastelGreen]", + "consumableName": "jA3zzS31zEitBxtRCk7oew" + }, + { + "fileName": "[HairDyePotionConsumable_PastelCyan]", + "consumableName": "P7HixsdkskmzgFMdFJ-WdA" + }, + { + "fileName": "[HairDyePotionConsumable_Teal]", + "consumableName": "5Ugg8k19n0WH8GPFCDWTCQ" + }, + { + "fileName": "[HairDyePotionConsumable_Red]", + "consumableName": "P1kCuPlRc0-NAg6JUlEH_g" + }, + { + "fileName": "[HairDyePotionConsumable_PastelPurple]", + "consumableName": "Hh_O_RejGE-_PubyM8xHaw" + }, + { + "fileName": "[HairDyePotionConsumable_PastelPink]", + "consumableName": "s2pxukIEt0uDeGVJmZayOw" + }, + { + "fileName": "[HairDyePotionConsumable_Pink]", + "consumableName": "vXL0rrAk70SFxJlaJk0uqQ" + }, + { + "fileName": "[HairDyePotionConsumable_PastelBlue]", + "consumableName": "UAOH6ccEbEmehJg8XW_D8w" + }, + { + "fileName": "[HairDyePotionConsumable_Yellow]", + "consumableName": "o0hG5O_eaUO0R8vPyW-Hvg" + }, + { + "fileName": "[HairDyePotionConsumable_Purple]", + "consumableName": "xRtKWR-D40GUW43R0EFpNg" + }, + { + "fileName": "[HairDyePotionConsumable_PastelOrange]", + "consumableName": "AQTYAh1it0GIGdgGO5sUHQ" + }, + { + "fileName": "[HairDyePotionConsumable_DarkBlue]", + "consumableName": "8vlVslaWaUyFS-iDHhxW9g" + }, + { + "fileName": "[HairDyePotionConsumable_PastelRed]", + "consumableName": "STFKahjHJ0SQJPfoDe4S1g" + }, + { + "fileName": "[HairDyePotionConsumable_DarkPink]", + "consumableName": "eJh_BQ5y4UWwiMJzBKcwMQ" + }, + { + "fileName": "[HairDyePotionConsumable_PastelYellow]", + "consumableName": "inIuPzhhOEmz1RI8mZtSLg" + }, + { + "fileName": "[CouponConsumable_20%Tokens]", + "consumableName": "50oCPkzd3EerBd7nYNdkCw" + } + ] +} \ No newline at end of file diff --git a/res/staticJoinInProgressLookup.json b/res/staticJoinInProgressLookup.json new file mode 100644 index 0000000..77d9a80 --- /dev/null +++ b/res/staticJoinInProgressLookup.json @@ -0,0 +1,38 @@ +{ + "f5fbd9c9-e853-4036-9d48-5f68e861af04": false, + "76d98498-60a1-430c-ab76-b54a29b7a163": false, + "cbad71af-0831-44d8-b8ef-69edafa841f6": true, + "4078dfed-24bb-4db7-863f-578ba48d726b": true, + "f6f7256c-e438-4299-b99e-d20bef8cf7e0": true, + "d9378c9f-80bc-46fb-ad1e-1bed8a674f55": true, + "3d474b26-26f7-45e9-9a36-9b02847d5e6f": true, + "d89f74fa-d51e-477a-a425-025a891dd499": false, + "e122fe98-e7db-49e8-a1b1-105424b6e1f0": true, + "a785267d-c579-42ea-be43-fec1992d1ca7": true, + "ff4c6427-7079-4f59-b22a-69b089420827": true, + "380d18b5-de9c-49f3-80f7-f4a95c1de161": true, + "58763055-2dfb-4814-80b8-16fac5c85709": true, + "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3": true, + "91e16e35-f48f-4700-ab8a-a1b79e50e51b": false, + "acc06e66-c2d0-4361-b0cd-46246a4c455c": false, + "949fa41f-4347-45c0-b7ac-489129174045": false, + "7e01cfe0-820a-406f-b1b3-0a5bf575235c": false, + "6d5eea4b-f069-4ed0-9916-0e2f07df0d03": true, + "239e676c-f12f-489f-bf3a-d4c383d692c3": true, + "9d6456ce-6264-48b4-808d-2d96b3d91038": true, + "253fa009-6e65-4c90-91a1-7137a56a267f": false, + "b010171f-4875-4e89-baba-61e878cd41e1": false, + "a067557f-ca32-43e6-b6e5-daaec60b4f5a": true, + "9932f88f-3929-43a0-a012-a40b5128e346": true, + "a75f7547-79eb-47c6-8986-6767abcb4f92": true, + "0a864c86-5a71-4e18-8041-8124e4dc9d98": true, + "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79": false, + "49cb8993-a956-43e2-86f4-1318f279b22a": false, + "ae929543-9a07-41d5-8ee9-dbbee8c36800": true, + "a95c349c-0f96-4c2d-a4c8-4969ffa8ea44": false, + "b7281665-a715-4051-826b-8e08e69c6172": true, + "3a636bd2-f896-424c-9225-c184522c0d87": false, + "882e9b96-7115-4b03-86f6-c0c9d8e22e00": true, + "cf61556d-68fd-4288-9ae5-7a512621e569": false, + "bf268f5f-b55b-41af-8628-32fa4b5d70b6": false +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 7c1ba2e..f6ef443 100644 --- a/src/main.ts +++ b/src/main.ts @@ -182,7 +182,7 @@ Server.Commands.addRootCommand(new Command({ else return null; }, zod: z.tuple([ - z.string().transform(Number) + z.coerce.number() ]), help: 'Get ping (in ms) to the server' })); \ No newline at end of file diff --git a/src/routes/accounts/routes/account.ts b/src/routes/accounts/routes/account.ts index 9f2a486..2d468b4 100644 --- a/src/routes/accounts/routes/account.ts +++ b/src/routes/accounts/routes/account.ts @@ -1,10 +1,11 @@ import { createHonoRoute } from "../../../util/import.ts"; -import { authenticate, galvanicError, GalvanicErrors, RateLimiter, recNetError } from "../../../util/api.ts"; +import { authenticate, galvanicError, GalvanicErrors, RateLimiter, recNetError, statusResponse } from "../../../util/api.ts"; import Server from "../../../server/server.ts"; import z from "zod"; -import { transformStringToEnum, typedZValidator } from "../../../util/validators.ts"; +import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts"; import { PlatformType } from "../../../server/platforms/types.ts"; import Steam from "../../../util/steam/steam.ts"; +import { HTTPStatus } from "@oneday/http-status"; export const route = createHonoRoute('/account'); @@ -28,7 +29,7 @@ route.app.get('/bulk', typedZValidator('query', bulkAccountQuerySchema), async c const postCreateRateLimiter = new RateLimiter(60, 3); const createAccountBodySchema = z.object({ - platform: z.string().transform(transformStringToEnum(PlatformType)), + platform: z.coerce.number().transform(transformCheckEnum(PlatformType)), platformId: z.string().min(14).max(20), deviceId: z.string().min(32).max(64) }); @@ -71,9 +72,16 @@ route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form' }); -route.app.use(authenticate); - -route.app.get('/me', c => { +route.app.get('/me', authenticate, c => { const profile = c.get('profile'); return c.json(profile.selfExport()); +}); + +const getAccountByIdParamSchema = z.object({ + id: z.coerce.number().max(Math.pow(2, 31)) +}); +route.app.get('/:id', typedZValidator('param', getAccountByIdParamSchema), async c => { + const prof = await Server.Profiles.get(c.req.valid('param').id); + if (prof) return c.json(prof.export()); + else return statusResponse(c, HTTPStatus.NotFound); }); \ No newline at end of file diff --git a/src/routes/api/routes/announcement.ts b/src/routes/api/routes/announcement.ts new file mode 100644 index 0000000..1ed5163 --- /dev/null +++ b/src/routes/api/routes/announcement.ts @@ -0,0 +1,8 @@ +import Server from "../../../server/server.ts"; +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute("/announcement"); + +route.app.get('/v1/get', c => { + return c.json(Server.getAnnouncements()); +}); \ No newline at end of file diff --git a/src/routes/api/routes/avatar.ts b/src/routes/api/routes/avatar.ts index 97a8ee5..634b763 100644 --- a/src/routes/api/routes/avatar.ts +++ b/src/routes/api/routes/avatar.ts @@ -24,4 +24,12 @@ route.app.post('/v2/set', typedZValidator('json', profileAvatarSchema), async c const outfit = c.req.valid('json'); await c.get('profile').Avatar.setAvatar(outfit); return c.status(200); +}); + +route.app.get('/v3/saved', c => { + return c.json([]); // stub +}); + +route.app.get('/v2/gifts', c => { + return c.json([]); // stub }); \ No newline at end of file diff --git a/src/routes/api/routes/challenge.ts b/src/routes/api/routes/challenge.ts new file mode 100644 index 0000000..9129d5e --- /dev/null +++ b/src/routes/api/routes/challenge.ts @@ -0,0 +1,19 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute('/challenge'); + +route.app.get('/v2/getCurrent', c => { + return c.json({ + ChallengeMapId: 0, + CompletedRequired: false, + StartAt: new Date(new Date().getTime() - 604_800_000).toISOString(), + EndAt: new Date(new Date().getTime() + 999_999_999_999).toISOString(), + ServerTime: new Date().toISOString(), + Challenges: [], + Gift: { + GiftDropId: 0, + Xp: 0, + Level: 0 + } + }) // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/checklist.ts b/src/routes/api/routes/checklist.ts new file mode 100644 index 0000000..3d2e49c --- /dev/null +++ b/src/routes/api/routes/checklist.ts @@ -0,0 +1,171 @@ +import { ObjectiveType } from "../../../server/objectives/base.ts"; +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute('/checklist'); + +route.app.get('/v1/current', c => { + return c.json([ + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + }, + { + Order: 1, + Objective: ObjectiveType.CompleteAnyDaily, + Count: 1, + CreditAmount: -1 + } + ]); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/communityboard.ts b/src/routes/api/routes/communityboard.ts new file mode 100644 index 0000000..f355f0e --- /dev/null +++ b/src/routes/api/routes/communityboard.ts @@ -0,0 +1,23 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute("/communityboard"); + +route.app.get('/v1/current', c => { + return c.json({ + FeaturedPlayer: { + Id: 1, + TitleOverride: "", + UrlOverride: "" + }, + FeaturedRoomGroup: { + Name: "", + FeaturedRooms: [] + }, + CurrentAnnouncement: { + Message: "Galvanic Corrosion", + MoreInfoUrl: "" + }, + InstagramImages: [], + Videos: [] + }); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/consumables.ts b/src/routes/api/routes/consumables.ts new file mode 100644 index 0000000..d32c70a --- /dev/null +++ b/src/routes/api/routes/consumables.ts @@ -0,0 +1,8 @@ +import Server from "../../../server/server.ts"; +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute("/consumables"); + +route.app.get('/v1/getUnlocked', c => { + return c.json(Server.Consumables.getAllDev()); +}); \ No newline at end of file diff --git a/src/routes/api/routes/equipment.ts b/src/routes/api/routes/equipment.ts new file mode 100644 index 0000000..aefaa8f --- /dev/null +++ b/src/routes/api/routes/equipment.ts @@ -0,0 +1,7 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute("/equipment"); + +route.app.get('/v2/getUnlocked', c => { + return c.json([]); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/images.ts b/src/routes/api/routes/images.ts new file mode 100644 index 0000000..fdb6b79 --- /dev/null +++ b/src/routes/api/routes/images.ts @@ -0,0 +1,7 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute('/images'); + +route.app.get('/v2/named', c => { + return c.json([]); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/objectives.ts b/src/routes/api/routes/objectives.ts new file mode 100644 index 0000000..6f1e936 --- /dev/null +++ b/src/routes/api/routes/objectives.ts @@ -0,0 +1,10 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute('/objectives'); + +route.app.get('/v1/myprogress', c => { + return c.json({ + Objectives: [], + ObjectiveGroups: [] + }); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/playerevents.ts b/src/routes/api/routes/playerevents.ts new file mode 100644 index 0000000..7eeda6b --- /dev/null +++ b/src/routes/api/routes/playerevents.ts @@ -0,0 +1,10 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute('/playerevents'); + +route.app.get('/v1/all', c => { + return c.json({ + Created: [], + Responses: [] + }) // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/players.ts b/src/routes/api/routes/players.ts index 9de241c..a7205ec 100644 --- a/src/routes/api/routes/players.ts +++ b/src/routes/api/routes/players.ts @@ -14,14 +14,14 @@ const getProgParamSchema = z.object({ id: z.coerce.number() }); route.app.get('/v1/progression/:id', authenticate, typedZValidator('param', getProgParamSchema), async c => { - return c.json(await c.get('profile').Reputation.export()); + return c.json(await c.get('profile').Progression.get()); }); const getProgBulkBodySchema = z.object({ Ids: z.union([z.array(z.coerce.number()), z.coerce.number()]) }); -route.app.post('/v1/progression/bulk', authenticate, typedZValidator('form', getProgBulkBodySchema), async c => { - const ids = c.req.valid('form').Ids; +route.app.post('/v1/progression/bulk', authenticate, typedZValidator('json', getProgBulkBodySchema), async c => { + const ids = c.req.valid('json').Ids; if (typeof ids == 'object') { const profs = await Server.Profiles.getMany(...ids); diff --git a/src/routes/api/routes/playersubscriptions.ts b/src/routes/api/routes/playersubscriptions.ts new file mode 100644 index 0000000..f8ee421 --- /dev/null +++ b/src/routes/api/routes/playersubscriptions.ts @@ -0,0 +1,7 @@ +import { createHonoRoute } from "../../../util/import.ts"; + +export const route = createHonoRoute("/playersubscriptions"); + +route.app.get('/v1/my', c => { + return c.json([]); // stub +}); \ No newline at end of file diff --git a/src/routes/api/routes/relationships.ts b/src/routes/api/routes/relationships.ts index 2905ae4..dbee762 100644 --- a/src/routes/api/routes/relationships.ts +++ b/src/routes/api/routes/relationships.ts @@ -8,7 +8,6 @@ route.app.get('/v2/get', c => { return c.json([]); }); -// deno-lint-ignore require-await -route.app.post('/v1/bulkignoreplatformusers', async c => { +route.app.post('/v1/bulkignoreplatformusers', c => { return statusResponse(c, HTTPStatus.OK); }); \ No newline at end of file diff --git a/src/routes/api/routes/rooms.ts b/src/routes/api/routes/rooms.ts index 69b4e9e..8494bab 100644 --- a/src/routes/api/routes/rooms.ts +++ b/src/routes/api/routes/rooms.ts @@ -2,6 +2,7 @@ import z from "zod"; import { createHonoRoute } from "../../../util/import.ts"; import { typedZValidator } from "../../../util/validators.ts"; import Server from "../../../server/server.ts"; +import { authenticate } from "../../../util/api.ts"; export const route = createHonoRoute("/rooms"); @@ -23,4 +24,13 @@ route.app.get('/v2/name/:name', typedZValidator('param', getRoomByNameParamSchem const room = await Server.Rooms.get(id); if (room == null) return await nxt(); else return c.json((await room.export()).Room); +}); + +route.app.get('/v2/myrooms', authenticate, async c => { + const myrooms = c.get('profile').Rooms.getRooms().values().toArray(); + const factories = await Server.Rooms.getMany(...myrooms); + const exs = await Promise.all(factories.map(factory => factory.export())); + const rooms = exs.map(ex => ex.Room); + + return c.json(rooms); }); \ No newline at end of file diff --git a/src/routes/auth/routes/cachedlogin.ts b/src/routes/auth/routes/cachedlogin.ts index 081b31d..238ef42 100644 --- a/src/routes/auth/routes/cachedlogin.ts +++ b/src/routes/auth/routes/cachedlogin.ts @@ -1,6 +1,6 @@ import z from "zod"; import { createHonoRoute } from "../../../util/import.ts"; -import { transformStringToEnum, typedZValidator } from "../../../util/validators.ts"; +import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts"; import { PlatformType } from "../../../server/platforms/types.ts"; import Server from "../../../server/server.ts"; import { authenticate } from "../../../util/api.ts"; @@ -11,7 +11,7 @@ const log = new Logging("CachedLoginDebug"); export const route = createHonoRoute("/cachedlogin"); const cachedLoginFetchParamSchema = z.object({ - platformType: z.string().transform(transformStringToEnum(PlatformType)), + platformType: z.coerce.number().transform(transformCheckEnum(PlatformType)), platformId: z.string().min(4) }); diff --git a/src/routes/auth/routes/connect.ts b/src/routes/auth/routes/connect.ts index 929cb24..522b76d 100644 --- a/src/routes/auth/routes/connect.ts +++ b/src/routes/auth/routes/connect.ts @@ -1,6 +1,6 @@ import { createHonoRoute } from "../../../util/import.ts"; import z from "zod"; -import { transformStringToEnum, typedZValidator } from "../../../util/validators.ts"; +import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts"; import { DeviceClass, PlatformType, TokenFormat, TokenType } from "../../../server/platforms/types.ts"; import { steamAuthTicketSchema } from "../../../server/platforms/base.ts"; import { gameVerString } from "../../api/routes/versioncheck.ts"; @@ -16,13 +16,13 @@ export const route = createHonoRoute("/connect"); const authBodyBaseSchema = z.object({ client_id: z.literal("recroom"), - platform: z.string().transform(Number).transform((arg, ctx) => { // we only support steam right now + platform: z.coerce.number().transform((arg, ctx) => { // we only support steam right now if (arg !== PlatformType.Steam) ctx.addIssue("platform was not Steam"); else return PlatformType.Steam; }), platform_id: z.string().min(4), device_id: z.string().min(4), - device_class: z.string().transform(transformStringToEnum(DeviceClass)), + device_class: z.string().transform(transformCheckEnum(DeviceClass)), time: z.coerce.date(), ver: z.literal(gameVerString), asid: z.coerce.number(), @@ -39,7 +39,7 @@ const authBodyBaseSchema = z.object({ const cachedLoginGrantSchema = authBodyBaseSchema.extend({ grant_type: z.literal('cached_login'), - account_id: z.string().transform(Number), + account_id: z.coerce.number(), }); const refreshTokenGrantSchema = authBodyBaseSchema.extend({ grant_type: z.literal('refresh_token'), @@ -97,24 +97,25 @@ route.app.post('/token', typedZValidator('form', tokenGrantSchema), async c => { const profile = await Server.Profiles.get(token.sub); if (!profile) return error(TokenRequestError.AccessDenied); - const accessToken = await Server.Platforms.getToken(profile.getId(), TokenType.Access); + const accessToken = await Server.Platforms.getToken(profile, TokenType.Access); + const refreshToken = await Server.Platforms.getToken(profile, TokenType.Refresh); return c.json({ access_token: accessToken, - refresh_token: form.refresh_token, + refresh_token: refreshToken, }); } catch (err) { log.w(`Authentication error (token req): ${(err as Error).stack}`); return error(TokenRequestError.InvalidClient); } } - + if (logins.find(login => login.accountId === form.account_id)) { const profile = await Server.Profiles.get(form.account_id); if (!profile) return error(TokenRequestError.InvalidRequest, "No such profile"); await Server.Platforms.updateLastLoginTime(form.platform, form.platform_id, form.account_id); - const accessToken = await Server.Platforms.getToken(profile.getId(), TokenType.Access); - const refreshToken = await Server.Platforms.getToken(profile.getId(), TokenType.Refresh); + const accessToken = await Server.Platforms.getToken(profile, TokenType.Access); + const refreshToken = await Server.Platforms.getToken(profile, TokenType.Refresh); return c.json({ access_token: accessToken, diff --git a/src/routes/match/root.ts b/src/routes/match/root.ts index 8c4fef2..5cf5964 100644 --- a/src/routes/match/root.ts +++ b/src/routes/match/root.ts @@ -1,50 +1,7 @@ import { createHonoRoute, routeImporter } from "../../util/import.ts"; -import { Context, Next } from "@hono/hono"; -import z from "zod"; -import { HonoEnv } from "../../util/types.ts"; -import { statusResponse } from "../../util/api.ts"; -import { HTTPStatus } from "@oneday/http-status"; -import Logging from "@proxnet/undead-logging"; - -const log = new Logging("MatchRoute"); export const route = createHonoRoute('/match'); -const loginLockBodySchema = z.object({ - LoginLock: z.uuidv4() -}); -export const loginLockMiddleware = async (c: Context, nxt: Next) => { - function unauthorized() { - return statusResponse(c, HTTPStatus.Unauthorized); - } - - if (c.req.header("Content-Type") !== "application/x-www-form-urlencoded") return unauthorized(); - try { - const form = await c.req.formData(); - - const body = await loginLockBodySchema.safeParseAsync(Object.fromEntries(form.entries())); - if (body.success) { - if (typeof c.get('profile') == 'undefined') { - log.w(`Profile was not set, cannot validate LoginLock. Was the request authorized?`); - return statusResponse(c, HTTPStatus.InternalServerError); - } - - const profile = c.get('profile'); - - const loginLock = await profile.Matchmaking.getLoginLock(); - if (!loginLock) await profile.Matchmaking.setLoginLock(body.data.LoginLock); - else if (body.data.LoginLock !== loginLock) { - log.w(`LoginLock did not match. The token for this profile could be compromised or the client is an unknown state.`); - return unauthorized(); - } - - return await nxt(); - } else return unauthorized(); - } catch { - return unauthorized(); - } -} - await routeImporter(route.app, 'src/routes/match/', [ 'routes' ]); \ No newline at end of file diff --git a/src/routes/match/routes/goto.ts b/src/routes/match/routes/goto.ts index 525f961..86ffb58 100644 --- a/src/routes/match/routes/goto.ts +++ b/src/routes/match/routes/goto.ts @@ -1,13 +1,76 @@ import { HTTPStatus } from "@oneday/http-status"; -import { statusResponse } from "../../../util/api.ts"; +import { authenticate, loginLockMiddleware, statusResponse } from "../../../util/api.ts"; import { createHonoRoute } from "../../../util/import.ts"; +import Server from "../../../server/server.ts"; +import z from "zod"; +import { typedZValidator } from "../../../util/validators.ts"; +import { type MatchmakingResponse } from "../../../server/matchmaking/base.ts"; +import { roomNameSchema } from "../../../server/rooms/base.ts"; export const route = createHonoRoute("/goto"); -route.app.post('/room/:roomName', c => { - return statusResponse(c, HTTPStatus.NotImplemented); +const gotoRoomBodySchema = z.object({ + CreatePrivateInstance: z.boolean().optional(), + ExpectedPlayerIds: z.array(z.int()).optional(), + BypassMovementModeRestriction: z.boolean().optional() }); -route.app.post('/room/:roomName/:subRoomName', c => { - return statusResponse(c, HTTPStatus.NotImplemented); -}); \ No newline at end of file +const gotoRoomParamSchema = z.object({ + roomName: roomNameSchema +}); +route.app.post('/room/:roomName', + authenticate, + loginLockMiddleware, + + typedZValidator('json', gotoRoomBodySchema), + typedZValidator('param', gotoRoomParamSchema), + + async c => { + const body = c.req.valid("json"); + + const res = await Server.Matchmaking.matchmake({ + roomName: c.req.param('roomName'), + private: body.CreatePrivateInstance, + profile: c.get('profile') + }); + if (!res) return statusResponse(c, HTTPStatus.InternalServerError, "Matchmaking failed"); + + const m: MatchmakingResponse = { + roomInstance: res.roomInstance ? res.roomInstance.export() : undefined, + errorCode: res.errorCode + } + return c.json(m); + } + +); + +const gotoSubroomParamSchema = gotoRoomParamSchema.extend({ + subRoomName: roomNameSchema +}); +route.app.post('/room/:roomName/:subRoomName', + + authenticate, + loginLockMiddleware, + + typedZValidator('json', gotoRoomBodySchema), + typedZValidator('param', gotoSubroomParamSchema), + + async c => { + const body = c.req.valid("json"); + + const res = await Server.Matchmaking.matchmake({ + roomName: c.req.param('roomName'), + subRoomName: c.req.param('subRoomName'), + private: body.CreatePrivateInstance, + profile: c.get('profile') + }); + if (!res) return statusResponse(c, HTTPStatus.InternalServerError, "Matchmaking failed"); + + const m: MatchmakingResponse = { + roomInstance: res.roomInstance ? res.roomInstance.export() : undefined, + errorCode: res.errorCode + } + return c.json(m); + } + +); \ No newline at end of file diff --git a/src/routes/match/routes/player.ts b/src/routes/match/routes/player.ts index b025bfe..c37ead6 100644 --- a/src/routes/match/routes/player.ts +++ b/src/routes/match/routes/player.ts @@ -1,20 +1,70 @@ -import { authenticate, statusResponse } from "../../../util/api.ts"; +import z from "zod"; +import Server from "../../../server/server.ts"; +import { authenticate, loginLockMiddleware, statusResponse } from "../../../util/api.ts"; import { createHonoRoute } from "../../../util/import.ts"; -import { loginLockMiddleware } from "../root.ts"; import { HTTPStatus } from "@oneday/http-status"; +import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts"; +import { PlayerStatusVisibility, VRMovementMode } from "../../../server/presence/base.ts"; export const route = createHonoRoute("/player"); -route.app.use(authenticate); - -route.app.post('/login', authenticate, loginLockMiddleware, async c => { +const playerIdsQuerySchema = z.object({ + id: z.union([z.coerce.number(), z.array(z.coerce.number())]) +}); +route.app.get('/', typedZValidator('query', playerIdsQuerySchema), async c => { + const id = c.req.valid('query').id; + + if (typeof id == 'object') { + const profs = await Server.Profiles.getMany(...id); + return c.json(profs.map(prof => Server.Presence.getPresence(prof).export())); + } else { + const prof = await Server.Profiles.get(id); + if (!prof) return c.json([]); + return c.json([Server.Presence.getPresence(prof).export()]); + } }); -route.app.post('/player/vrmovementmode', authenticate, loginLockMiddleware, async c => { - return statusResponse(c, HTTPStatus.OK); // stub +route.app.post('/login', authenticate, loginLockMiddleware, c => { + const pres = Server.Presence.getPresence(c.get('profile')); + pres.updateLastSeen(); + + return statusResponse(c, HTTPStatus.OK); +}); +route.app.post('/logout', authenticate, loginLockMiddleware, c => { + const pres = Server.Presence.getPresence(c.get('profile')); + pres.updateLastSeen(); + + pres.setStatusVisibility(PlayerStatusVisibility.Offline); + + c.get('profile').updateInstance(null); + + Server.Instances.clearEmptyInstances(); + + return statusResponse(c, HTTPStatus.OK); }); -route.app.post('/player/statusvisibility', authenticate, loginLockMiddleware, async c => { - return statusResponse(c, HTTPStatus.OK); // stub +const vrMovementModeBodySchema = z.object({ + vrMovementMode: z.coerce.number().transform(transformCheckEnum(VRMovementMode)) +}); +route.app.put('/vrmovementmode', authenticate, typedZValidator('form', vrMovementModeBodySchema), c => { + const pres = Server.Presence.getPresence(c.get('profile')); + pres.updateLastSeen(); + + return statusResponse(c, HTTPStatus.OK); +}); +const statusVisibilityBodySchema = z.object({ + statusVisibility: z.coerce.number().transform(transformCheckEnum(PlayerStatusVisibility)) +}); +route.app.put('/statusvisibility', authenticate, typedZValidator('form', statusVisibilityBodySchema), c => { + const pres = Server.Presence.getPresence(c.get('profile')); + pres.updateLastSeen(); + + return statusResponse(c, HTTPStatus.OK); +}); + +route.app.post('/heartbeat', authenticate, loginLockMiddleware, c => { + const pres = Server.Presence.getPresence(c.get('profile')); + + return c.json(pres.export()); }); \ No newline at end of file diff --git a/src/server/commands/cmdtypes.ts b/src/server/commands/cmdtypes.ts index 22e816e..1ba5c7e 100644 --- a/src/server/commands/cmdtypes.ts +++ b/src/server/commands/cmdtypes.ts @@ -1,2 +1,20 @@ +import type Profile from "../profiles/profile.ts"; + // deno-lint-ignore no-explicit-any -export type CommandExec = (...args: any[]) => unknown | Promise; \ No newline at end of file +export type CommandExec = (...args: any[]) => unknown | Promise; + +export enum CommandSenderType { + Console, + Profile +} +export interface CommandSenderBase { + type: CommandSenderType +} +export interface ConsoleSender extends CommandSenderBase { + type: CommandSenderType.Console +} +export interface ProfileSender extends CommandSenderBase { + type: CommandSenderType.Profile, + prof: Profile +} +export type CommandSender = ConsoleSender | ProfileSender; \ No newline at end of file diff --git a/src/server/commands/command.ts b/src/server/commands/command.ts index 0d161c6..a18f685 100644 --- a/src/server/commands/command.ts +++ b/src/server/commands/command.ts @@ -34,6 +34,10 @@ export default class Command { return this.exec(...args); else if (!root) return new Error('No execution target for this root'); + if (root === 'help') return JSON.stringify(this.subCmds.values() + .map(cmd => cmd.getKey()).toArray() + .reduce((prev, accumulator) => prev.concat(accumulator), [])); + const cmd = this.subCmds.find(cmd => cmd.getKey().includes(root)); if (cmd) { const newArgs = args.slice(1); diff --git a/src/server/commands/commands.ts b/src/server/commands/commands.ts index f1242b2..f36a254 100644 --- a/src/server/commands/commands.ts +++ b/src/server/commands/commands.ts @@ -1,4 +1,5 @@ import { ServerContentBase } from "../ContentBase.ts"; +import { CommandSender, CommandSenderType } from "./cmdtypes.ts"; import type Command from "./command.ts"; export class CommandsBase extends ServerContentBase { @@ -18,12 +19,20 @@ export class CommandsBase extends ServerContentBase { this.#cmds.delete(cmd); } - async dispatch(...args: string[]): Promise { + async dispatch(sender: CommandSender, ...args: string[]): Promise { + if (sender.type == CommandSenderType.Profile) + if (await sender.prof.getRole() !== "developer") return new Error("Unauthorized"); + const root = args[0]; if (typeof root !== 'string') return new Error("Root command must be of primitive type 'string'"); else { + if (root === "help") return JSON.stringify(this.#cmds.values() + .map(cmd => cmd.getKey()).toArray() + .reduce((prev, accumulator) => prev.concat(accumulator), [])); + const cmd = this.#cmds.values().toArray().find(cmd => cmd.getKey().includes(root)); if (cmd) { + const val = await cmd.dispatch(...args.slice(1)); if (val == null) return "null"; else if (typeof val == 'string') return `"${val}"`; diff --git a/src/server/consumables/base.ts b/src/server/consumables/base.ts new file mode 100644 index 0000000..71269ec --- /dev/null +++ b/src/server/consumables/base.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { ServerContentBase } from "../ContentBase.ts"; +import { Consumable, ConsumableCollectionRuntimeConfig } from "./types.ts"; +import { RootPath } from "../../util/path.ts"; +import { PlatformMask } from "../platforms/types.ts"; + +export class ServerConsumablesBase extends ServerContentBase { + + #consumableConfig: ConsumableCollectionRuntimeConfig | null = null; + + protected override start() { + this.#consumableConfig = JSON.parse(Deno.readTextFileSync(path.join(RootPath, '/res/consumables.json'))); + } + + getAllDev(): Consumable[] { + if (this.#consumableConfig == null) return []; + else return this.#consumableConfig.consumablePrefabData.map(cons => ({ + Id: this.#consumableConfig!.consumablePrefabData.indexOf(cons), + ConsumableItemDesc: cons.consumableName, + PlatformMask: this.server.generateMask(PlatformMask.All), + CreatedAt: new Date().toISOString(), + Count: 99999999, + InitialCount: 99999999, + UnlockedLevel: 1, + IsActive: false + })); + } + +} \ No newline at end of file diff --git a/src/server/consumables/types.ts b/src/server/consumables/types.ts new file mode 100644 index 0000000..7f6ffba --- /dev/null +++ b/src/server/consumables/types.ts @@ -0,0 +1,19 @@ +export interface ConsumablePrefab { + fileName: string, + consumableName: string +} +export interface ConsumableCollectionRuntimeConfig { + consumablePrefabData: ConsumablePrefab[] +} + +export interface Consumable { + Id: number, + ConsumableItemDesc: string, + PlatformMask: number, + CreatedAt: string, + Count: number, + InitialCount: number, + UnlockedLevel: number, + IsActive: boolean, + ActiveDurationMinutes?: number +} \ No newline at end of file diff --git a/src/server/instances/Instance.ts b/src/server/instances/Instance.ts index 509449e..f2f53ed 100644 --- a/src/server/instances/Instance.ts +++ b/src/server/instances/Instance.ts @@ -1,45 +1,104 @@ import { CloudRegionCode } from "../../util/photon.ts"; import type Profile from "../profiles/profile.ts"; +import { RoomFactory } from "../rooms/internal/RoomFactory.ts"; +import { SubroomFactory } from "../rooms/internal/SubroomFactory.ts"; +import { type ServerBase } from "../server.ts"; import { RoomInstance, RoomLocation } from "./types.ts"; export interface InstanceCreationOptions { - roomId: number, - subRoomId: number, + room: RoomFactory, + subroom: SubroomFactory, name: string, - maxCapacity: number, - private?: boolean + private?: boolean, + eventId?: number } export class Instance { #createdAt = new Date(); - #players: Set = new Set(); + #server: ServerBase; + #init: boolean = true; #instanceId: number; - #roomId: number; - #subRoomId: number; - #location: RoomLocation; - #name: string; - #maxCapacity: number; + #roomId: number = -1; + #subRoomId: number = -1; + #location: RoomLocation = RoomLocation.MakerRoom; + #name: string = "Uninitialized Instance"; + #maxCapacity: number = 8; #isFull: boolean = false; - #isPrivate: boolean; + #isPrivate: boolean = false; #isInProgress: boolean = false; #photonRegionId: string = CloudRegionCode.us; - #photonRoomId: string; + #photonRoomId: string = "uninit"; #dataBlob?: string; - #eventId?: number + #eventId?: number; - constructor(id: number, location: RoomLocation, options: InstanceCreationOptions) { + get instanceId() { return this.#instanceId } + + get roomId() { return this.#roomId } + set roomId(data) { this.#roomId = data } + + get subRoomId() { return this.#subRoomId } + set subRoomId(data) { this.#subRoomId = data } + + get location() { return this.#location } + set location(data) { this.#location = data } + + get name() { return this.#name } + set name(data) { this.#name = data } + + get maxCapacity() { return this.#maxCapacity } + set maxCapacity(data) { this.#maxCapacity = data } + + get isFull() { return this.#isFull } + set isFull(data) { this.#isFull = data } + + get isPrivate() { return this.#isPrivate } + set isPrivate(data) { this.#isPrivate = data } + + get isInProgress() { return this.#isInProgress } + set isInProgress(data) { this.#isInProgress = data } + + get photonRegionId() { return this.#photonRegionId } + set photonRegionId(data) { this.#photonRegionId = data } + + get photonRoomId() { return this.#photonRoomId } + set photonRoomId(data) { this.#photonRoomId = data } + + get dataBlob() { return this.#dataBlob } + set dataBlob(data) { this.#dataBlob = data } + + get eventId() { return this.#eventId } + set eventId(data) { this.#eventId = data } + + supportsJoinInProgress: boolean; + + constructor(server: ServerBase, id: number, options: InstanceCreationOptions) { this.#instanceId = id; - this.#location = location; + this.location = options.subroom.RoomSceneLocationId; + this.#server = server; - this.#roomId = options.roomId; - this.#subRoomId = options.subRoomId; - this.#isPrivate = typeof options.private == 'boolean' ? options.private : false; - this.#name = options.name; - this.#maxCapacity = options.maxCapacity; - this.#photonRoomId = `GCR-${this.#instanceId}`; + this.roomId = options.room.getRoomId(); + this.subRoomId = options.subroom.RoomSceneId; + this.isPrivate = typeof options.private == 'boolean' ? options.private : false; + this.name = options.name; + this.maxCapacity = options.subroom.MaxPlayers; + this.photonRoomId = `GCR-${this.instanceId}`; + + this.supportsJoinInProgress = server.Rooms.sceneSupportsJoinInProgress(options.room, options.subroom); + + this.#init = false; + } + + /** + * Can be heavy, so promise is used + */ + // deno-lint-ignore require-await + async #multiPresenceUpdate() { + if (this.#init) return; + for (const prof of this.#players) + prof.getSocketHandler()?.sendNotification("PresenceUpdate", this.#server.Presence.getPresence(prof).export()); } getPlayers() { @@ -61,21 +120,26 @@ export class Instance { export() { const inst: RoomInstance = { - roomInstanceId: this.#instanceId, - roomId: this.#roomId, - subRoomId: this.#subRoomId, - location: this.#location, - name: this.#name, - maxCapacity: this.#maxCapacity, - isFull: this.#isFull, - isPrivate: this.#isPrivate, - isInProgress: this.#isInProgress, - photonRegionId: this.#photonRegionId, - photonRoomId: this.#photonRoomId, - dataBlob: this.#dataBlob, - eventId: this.#eventId + roomInstanceId: this.instanceId, + roomId: this.roomId, + subRoomId: this.subRoomId, + location: this.location, + name: this.name, + maxCapacity: this.maxCapacity, + isFull: this.isFull, + isPrivate: this.isPrivate, + isInProgress: this.isInProgress, + photonRegionId: this.photonRegionId, + photonRoomId: this.photonRoomId, + dataBlob: this.dataBlob, + eventId: this.eventId }; return inst; } + /** + * Current player count (instance size) + */ + get size() { return this.getPlayers().length } + } \ No newline at end of file diff --git a/src/server/instances/base.ts b/src/server/instances/base.ts index 7d1334d..f3a0225 100644 --- a/src/server/instances/base.ts +++ b/src/server/instances/base.ts @@ -1,6 +1,9 @@ import Logging from "@proxnet/undead-logging"; import { ServerContentBase } from "../ContentBase.ts"; -import { type Instance } from "./Instance.ts"; +import { Instance, InstanceCreationOptions } from "./Instance.ts"; +import Command from "../commands/command.ts"; +import z from "zod"; +import { PushNotificationId } from "../socket/signalr/types.ts"; const log = new Logging("Instances"); @@ -8,40 +11,80 @@ export class InstanceManager extends ServerContentBase { #instances: Set = new Set(); - clearEmptyInstances() { + protected override start() { + this.server.Commands.addRootCommand(new Command({ + key: ["inst", "i", "instance", "instances"], + subcommands: [ + new Command({ + key: ["getall", "all", "fetchall", "list"], + exec: () => { + return this.#instances.values().toArray().map(inst => inst.export()); + }, + zod: z.tuple([]), + help: "Get all instances" + }), + new Command({ + key: ["kicklive", "modkick", "quitgame"], + exec: (id: number) => { + const inst = this.server.Instances.getInstance(id); + if (!inst) return false; + + inst.getPlayers().forEach(prof => prof.getSocketHandler()?.sendNotification(PushNotificationId.ModerationKick)); + return true; + }, + zod: z.tuple([z.coerce.number()]), + help: "Send ModerationKick to all players in an instance. Returns true if successful." + }) + ] + })); + } + + /** + * Can be heavy if instance count is high. + */ + // deno-lint-ignore require-await + async clearEmptyInstances() { log.i(`Starting instance purge\n Before: ${ this.#instances.size } instances, ${ this.#instances.values().reduce((prev, current) => prev + current.getPlayers().length, 0) } players`); - return new Promise(() => { - for (const inst of this.#instances) { - if (inst.getPlayers().length === 0) this.deleteInstance(inst); - } + for (const inst of this.#instances) + if (inst.getPlayers().length === 0) this.deleteInstance(inst); + - log.i(`Instance purge complete\n After: ${ - this.#instances.size - } instances, ${ - this.#instances.values().reduce((prev, current) => prev + current.getPlayers().length, 0) - } players`); - }); + log.i(`Instance purge complete\n After: ${ + this.#instances.size + } instances, ${ + this.#instances.values().reduce((prev, current) => prev + current.getPlayers().length, 0) + } players`); } getAllInstances() { - + return this.#instances; } - registerInstance(inst: Instance) { - + #generateAvailableId() { + let id = Math.round(Math.random() * Math.pow(2, 31)); + while (this.#instances.values().find(inst => inst.instanceId === id)) id = this.#generateAvailableId(); + return id; + } + #registerInstance(inst: Instance) { + if (!this.#instances.has(inst)) this.#instances.add(inst); + } + createInstance(options: InstanceCreationOptions) { + const inst = new Instance(this.server, this.#generateAvailableId(), options); + this.#instances.add(inst); + return inst; } getInstance(id: number) { - + return this.#instances.values().find(inst => inst.instanceId === id); } deleteInstance(inst: Instance) { - + this.#instances.delete(inst); } } \ No newline at end of file diff --git a/src/server/instances/types.ts b/src/server/instances/types.ts index b5a1d19..b63dfd6 100644 --- a/src/server/instances/types.ts +++ b/src/server/instances/types.ts @@ -1,3 +1,5 @@ +import { type Instance } from "./Instance.ts"; + export enum RoomLocation { Calibration = "f5fbd9c9-e853-4036-9d48-5f68e861af04", DormRoom = "76d98498-60a1-430c-ab76-b54a29b7a163", @@ -52,4 +54,8 @@ export interface RoomInstance { photonRoomId: string; dataBlob?: string; eventId?: number; +} + +export interface InstanceUpdatedEvent { + instance: Instance } \ No newline at end of file diff --git a/src/server/matchmaking/base.ts b/src/server/matchmaking/base.ts index 6552a8c..76cd0c1 100644 --- a/src/server/matchmaking/base.ts +++ b/src/server/matchmaking/base.ts @@ -1,7 +1,121 @@ +import Logging from "@proxnet/undead-logging"; import { ServerContentBase } from "../ContentBase.ts"; +import { Instance } from "../instances/Instance.ts"; +import { type RoomInstance } from "../instances/types.ts"; +import type Profile from "../profiles/profile.ts"; +import { RoomDataTypes } from "../rooms/internal/RoomDataTypes.ts"; +import { type RoomFactory } from "../rooms/internal/RoomFactory.ts"; +import { MatchmakingErrorCode } from "./types.ts"; + +const log = new Logging("Matchmaking"); + +export interface MatchmakingOptions { + roomName: string, + subRoomName?: string, + private?: boolean, + instanceId?: number, + profile: Profile +} + +export interface InternalMatchmakingResponse { + errorCode: MatchmakingErrorCode, + roomInstance?: Instance +} +export interface MatchmakingResponse { + errorCode: MatchmakingErrorCode, + roomInstance?: RoomInstance +} export class ServerMatchmakingBase extends ServerContentBase { - + async matchmake(options: MatchmakingOptions): Promise { + + function getInstanceName() { + if (options.roomName === 'DormRoom') + return `@${options.profile.getUsername()}'s Dorm`; + else { + if (options.subRoomName || options.subRoomName !== "Home") return `^${options.roomName}.${options.subRoomName}`; + else return `^${options.roomName}`; + } + } + + if (options.instanceId) { + + const instance = this.server.Instances.getInstance(options.instanceId); + if (instance) { + + if (instance.hasPlayer(options.profile)) return { errorCode: MatchmakingErrorCode.AlreadyInTargetInstance } + else { + if (instance.isFull) return { errorCode: MatchmakingErrorCode.InsufficientSpace } + else if (instance.isPrivate) return { errorCode: MatchmakingErrorCode.RoomInstanceIsPrivate } + + options.profile.updateInstance(instance); + await this.server.Instances.clearEmptyInstances(); + + return { errorCode: MatchmakingErrorCode.Success, roomInstance: instance }; + } + + } else return { errorCode: MatchmakingErrorCode.NoSuchGame }; + + } else { + + let targetRoom: RoomFactory | null = null; + if (options.roomName == 'DormRoom') targetRoom = await this.server.Rooms.getPlayerDorm(options.profile); + else targetRoom = await this.server.Rooms.getByName(options.roomName); + + if (!targetRoom) return { errorCode: MatchmakingErrorCode.NoSuchRoom }; + if (targetRoom.Accessibility == RoomDataTypes.RoomAccessibility.Private && targetRoom.CreatorPlayerId !== options.profile.getId()) + return { errorCode: MatchmakingErrorCode.RoomIsPrivate }; + if (targetRoom.State !== RoomDataTypes.RoomState.Active) return { errorCode: MatchmakingErrorCode.RoomIsNotActive }; + + await this.server.Instances.clearEmptyInstances(); + + let allInstances = this.server.Instances.getAllInstances(); + const subroom = (await targetRoom.getAllSubrooms()).values().find(factory => factory.Name == options.subRoomName); + + // if a subroom was specified, filter instances that + if (subroom) allInstances = new Set(allInstances.values().filter(inst => inst.subRoomId == subroom.RoomSceneId)); + + // filter out instances that are in progress and do not support joininprogress + allInstances = new Set(allInstances.values().filter(inst => !(inst.isInProgress && !inst.supportsJoinInProgress))); + + // sort instances + allInstances = new Set(allInstances.values().toArray().sort((a, b) => a.size + b.size)); + + const foundInstance = allInstances.values().toArray()[0]; + if (!foundInstance) { + + const matchmakeableSubrooms = (await targetRoom.getAllSubrooms()).values().filter(scene => scene.CanMatchmakeInto).toArray(); + const index = Math.floor(Math.random() * matchmakeableSubrooms.length); + + log.d(`Scene ${matchmakeableSubrooms[index].RoomSceneId} was chosen for matchmaking into new instance of room ${targetRoom.getRoomId()}`); + const newInst = this.server.Instances.createInstance({ + room: targetRoom, + subroom: matchmakeableSubrooms[index], + private: options.private, + name: getInstanceName() + }); + + options.profile.updateInstance(newInst); + this.server.Instances.clearEmptyInstances(); + + return { errorCode: MatchmakingErrorCode.Success, roomInstance: newInst }; + + } else { + + const currentInst = options.profile.getInstance(); + if (currentInst?.instanceId === foundInstance.instanceId) + return { errorCode: MatchmakingErrorCode.AlreadyInBestInstance }; + + options.profile.updateInstance(foundInstance); + this.server.Instances.clearEmptyInstances(); + + return { errorCode: MatchmakingErrorCode.Success, roomInstance: foundInstance }; + + } + + } + + } } \ No newline at end of file diff --git a/src/server/matchmaking/types.ts b/src/server/matchmaking/types.ts index 6ee0dab..5e562f1 100644 --- a/src/server/matchmaking/types.ts +++ b/src/server/matchmaking/types.ts @@ -1,4 +1,4 @@ -enum MatchmakingErrorCode { +export enum MatchmakingErrorCode { Success, NoSuchGame, PlayerNotOnline, diff --git a/src/server/platforms/base.ts b/src/server/platforms/base.ts index dd570a4..2a86b49 100644 --- a/src/server/platforms/base.ts +++ b/src/server/platforms/base.ts @@ -1,15 +1,19 @@ import z from "zod"; import Command from "../commands/command.ts"; import { ServerContentBase } from "../ContentBase.ts"; -import { transformCheckEnum, transformStringToEnum } from "../../util/validators.ts"; +import { transformCheckEnum } from "../../util/validators.ts"; import { sign } from "@hono/hono/jwt"; import { CachedLogin, DbCachedLogin, PlatformMask, PlatformType, TokenFormat, TokenType } from "./types.ts"; +import type Profile from "../profiles/profile.ts"; +import { getNetConfig } from "../../net.ts"; export const steamAuthTicketSchema = z.object({ Ticket: z.string().min(256), AppId: z.literal("471710") }); +const netConfig = getNetConfig(); + export class PlatformsManager extends ServerContentBase { static platformsKey = "platforms"; @@ -18,15 +22,18 @@ export class PlatformsManager extends ServerContentBase { return [PlatformsManager.platformsKey, ...keys.filter(val => typeof val == 'string')]; } - async getToken(accountId: number, type: TokenType) { + async getToken(prof: Profile, type: TokenType) { const secret = Deno.env.get('SECRET'); if (!secret) throw new Error("No SECRET in env. Did you forget to set it?"); - + const exp = type == TokenType.Access ? Math.round(Date.now() / 1000) + 21_600 : Math.round(Date.now() / 1000) + 31_556_952; + const token: TokenFormat = { typ: type, - sub: accountId, - iss: "https://yarns.proxnet.dev/auth/", - exp: type == TokenType.Access ? Math.round(Date.now() / 1000) + 21_600 : Math.round(Date.now() / 1000) + 31_556_952 + sub: prof.getId(), + role: await prof.getRole(), + iss: `${netConfig.securePublicHost ? "https" : "http"}://${netConfig.publicHost}/auth`, + iat: Math.round(Date.now() / 1000) - 5, + exp } return await sign(JSON.parse(JSON.stringify(token)), secret); } @@ -138,9 +145,9 @@ export class PlatformsManager extends ServerContentBase { return await this.addCachedLogin(type, platformId, accountId); }, zod: z.tuple([ - z.string().transform(transformStringToEnum(PlatformType)), + z.coerce.number().transform(transformCheckEnum(PlatformType)), z.string(), - z.string().transform(Number) + z.coerce.number() ]), help: 'Add a cachedlogin for platformId: , , ' }), @@ -150,9 +157,9 @@ export class PlatformsManager extends ServerContentBase { return await this.deleteCachedLogin(type, platformId, accountId); }, zod: z.tuple([ - z.string().transform(transformStringToEnum(PlatformType)), + z.coerce.number().transform(transformCheckEnum(PlatformType)), z.string(), - z.string().transform(Number) + z.coerce.number() ]), help: 'Remove a cachedlogin for platformId: , , ' }) diff --git a/src/server/platforms/types.ts b/src/server/platforms/types.ts index 81bc958..e0691d4 100644 --- a/src/server/platforms/types.ts +++ b/src/server/platforms/types.ts @@ -8,14 +8,13 @@ export interface TokenFormatBase { export interface TokenFormat extends TokenFormatBase { iss: string, exp: number, + iat: number, sub: number, + role: ProfileRole } -export enum ProfileRole { - Developer = 'developer', - Web = 'webClient', - Game = 'gameClient' -} +export const ProfileRole = ["developer", "gameClient", "webClient"] as const; +export type ProfileRole = typeof ProfileRole[number]; export enum PlatformType { All = -1, diff --git a/src/server/presence/base.ts b/src/server/presence/base.ts index 06dcda2..2e84e28 100644 --- a/src/server/presence/base.ts +++ b/src/server/presence/base.ts @@ -4,6 +4,9 @@ import { DeviceClass } from "../platforms/types.ts"; import Profile from "../profiles/profile.ts"; import { type ServerBase } from "../server.ts"; import { RoomInstance } from "../instances/types.ts"; +import Command from "../commands/command.ts"; +import z from "zod"; +import { PushNotificationId } from "../socket/signalr/types.ts"; export enum VRMovementMode { TELEPORT, @@ -33,7 +36,6 @@ export class Presence { #statusVisibility: PlayerStatusVisibility = PlayerStatusVisibility.Offline; #deviceClass: DeviceClass = DeviceClass.Unknown; - #roomInstance: RoomInstance | null = null; #vrMovementMove: VRMovementMode | undefined; #lastExported: Date = new Date(); @@ -63,12 +65,16 @@ export class Presence { this.#statusVisibility = sv; this.updateLastSeen(); this.update(); + + this.#server.emit('presence.update', { profile: this.#profile, presence: this }); } setVRMovementMode(mm: VRMovementMode) { this.#vrMovementMove = mm; this.updateLastSeen(); this.update(); + + this.#server.emit('presence.update', { profile: this.#profile, presence: this }); } getLastExported() { @@ -87,14 +93,14 @@ export class Presence { statusVisibility: this.#statusVisibility, deviceClass: this.#deviceClass, vrMovementMode: this.#vrMovementMove, - roomInstance: this.#roomInstance + roomInstance: this.#profile.getInstance()?.export() ?? null } return e; } } -export class PresenceBase extends ServerContentBase { +export class ServerPresenceBase extends ServerContentBase { #log = new Logging("Presence"); @@ -119,9 +125,32 @@ export class PresenceBase extends ServerContentBase { override start() { this.#intervalId = setInterval(() => { + if (this.#presenceMap.size === 0) return; + this.#log.i('Clearing dead presences'); this.#deleteDeadPresences(); }, 300_000); + + this.server.Commands.addRootCommand(new Command({ + key: ["presence", "pres"], + subcommands: [ + new Command({ + key: ["quit", "quitgame"], + exec: async (pid: number) => { + const prof = await this.server.Profiles.get(pid); + if (!prof) return false; + + const socket = prof.getSocketHandler(); + if (!socket) return false; + + socket.sendNotification(PushNotificationId.ModerationQuitGame); + return true; + }, + zod: z.tuple([z.coerce.number()]), + help: "Sends ModerationQuitGame to a player's socket if it is connected. Returns true if successful." + }) + ] + })); } override destroy() { diff --git a/src/server/profiles/manager.ts b/src/server/profiles/manager.ts index dcc45f1..ec5e8c8 100644 --- a/src/server/profiles/manager.ts +++ b/src/server/profiles/manager.ts @@ -4,7 +4,7 @@ import Profile from "./profile.ts"; import { SelfAccount, type RecNetAccount } from "./types/profile.ts"; import Command from "./../commands/command.ts"; import z from "zod"; -import { PlatformMask, PlatformType, ProfileRole } from "../platforms/types.ts"; +import { PlatformMask, PlatformType, ProfileRole, TokenType } from "../platforms/types.ts"; import Logging from "@proxnet/undead-logging"; const profiles: Map = new Map(); @@ -116,7 +116,7 @@ class ProfileManagerBase extends ServerContentBase { else return prof.export(); }, zod: z.tuple([ - z.string().transform(Number) + z.coerce.number() ]), help: 'Fetch a profile: ' }), @@ -137,9 +137,9 @@ class ProfileManagerBase extends ServerContentBase { else return await profile.getRole(); }, zod: z.tuple([ - z.string().transform(Number) + z.coerce.number() ]), - help: 'Set the profile role: ' + help: 'Get profile role: ' }), new Command({ key: ['setrole', 'sr'], @@ -150,9 +150,9 @@ class ProfileManagerBase extends ServerContentBase { }, zod: z.tuple([ z.coerce.number(), - z.string() + z.literal(ProfileRole) ]), - help: 'Set the profile role: ' + help: 'Set profile role: ' }), new Command({ key: ['settings', 'setting'], @@ -163,6 +163,16 @@ class ProfileManagerBase extends ServerContentBase { }, zod: z.tuple([z.coerce.number()]), help: "Get player settings" + }), + new Command({ + key: ["refreshtoken", "simutoken"], + exec: async (id: number) => { + const profile = await this.get(id); + if (profile) return await this.server.Platforms.getToken(profile, TokenType.Refresh); + else return null; + }, + zod: z.tuple([z.coerce.number()]), + help: "Get a profile's refresh token / simulate generation of refresh token" }) ] })); diff --git a/src/server/profiles/profile.ts b/src/server/profiles/profile.ts index 4496a0b..c02d23b 100644 --- a/src/server/profiles/profile.ts +++ b/src/server/profiles/profile.ts @@ -59,15 +59,15 @@ class Profile { } constructProfilePropertyKey(...keys: (string | undefined)[]) { - return [ ProfileManagerBase.profilesKey, this.#id, ...keys.filter(val => typeof val == 'string') ]; + return [ProfileManagerBase.profilesKey, this.#id, ...keys.filter(val => typeof val == 'string')]; } getUsername() { return this.#selfAcc.username; } async setUsername(username: string) { - this.#kv.getKv().delete([ ProfileManagerBase.profilesKey, this.#selfAcc.username ]); - this.#kv.getKv().set([ ProfileManagerBase.profilesKey, username ], this.getId()); + this.#kv.getKv().delete([ProfileManagerBase.profilesKey, this.#selfAcc.username]); + this.#kv.getKv().set([ProfileManagerBase.profilesKey, username], this.getId()); this.#selfAcc.username = username; await this.#saveSelfAcc(); @@ -81,7 +81,7 @@ class Profile { await this.#saveSelfAcc(); } - async getBio(){ + async getBio() { const key = this.constructProfilePropertyKey('bio'); const val = await this.#kv.getKv().get(key); if (!val.value) return null; @@ -136,7 +136,17 @@ class Profile { getInstance() { return this.#instance; } - setInstance(inst: Instance) { + updateInstance(inst: Instance | null) { + if (inst == null) { + if (this.#instance) this.#instance.removePlayer(this); + this.#instance = null; + return; + } + + if (this.#instance) this.#instance.removePlayer(this); + inst.addPlayer(this); + + this.#server.emit('presence.update', { profile: this, presence: this.#server.Presence.getPresence(this) }); this.#instance = inst; } diff --git a/src/server/rooms/base.ts b/src/server/rooms/base.ts index ba25b7d..f4c5b25 100644 --- a/src/server/rooms/base.ts +++ b/src/server/rooms/base.ts @@ -8,8 +8,13 @@ import { AGRoom, AGRoomLocation, AGRoomRuntimeConfig } from "./internal/ClientRo import Command from "../commands/command.ts"; import z from "zod"; import { RoomLocation } from "../instances/types.ts"; +import { RootPath } from "../../util/path.ts"; +import path from "node:path"; +import { SubroomFactory } from "./internal/SubroomFactory.ts"; + +export const roomNameSchema = z.string().min(4).max(128).regex(/^[A-Za-z0-9._-]+$/); +export const roomIdSchema = z.coerce.number().min(1).max(Math.pow(2, 31)); -const roomIdSchema = z.coerce.number().min(1).max(Math.pow(2, 31)); export class ServerRoomsBase extends ServerContentBase { #subroomKv = new KV('subrooms', true); @@ -21,19 +26,24 @@ export class ServerRoomsBase extends ServerContentBase { static roomNamesKey = "room_names"; static playerDormsKey = "dorms"; - #agrooms: Set = new Set(); - #baserooms: Set = new Set(); + #agroomIds: Set = new Set(); + #baseroomIds: Set = new Set(); + #agRoomRuntimeConfig: AGRoomRuntimeConfig | null = null; + #joinInProgressLookup: Record | null = null; override async start() { await this.#subroomKv.init(); this.#log.i('[sub]rooms database initialized'); const agrooms = await this.kv.getKv().get>([ServerRoomsBase.agRoomIdsKey]); - if (agrooms.value !== null) this.#agrooms = agrooms.value; - this.#log.i(`${this.#agrooms.size} AG rooms exist`); + if (agrooms.value !== null) this.#agroomIds = agrooms.value; + this.#log.i(`${this.#agroomIds.size} AG rooms exist`); const baserooms = await this.kv.getKv().get>([ServerRoomsBase.baseRoomIdsKey]); - if (baserooms.value !== null) this.#baserooms = baserooms.value; + if (baserooms.value !== null) this.#baseroomIds = baserooms.value; + + this.#agRoomRuntimeConfig = JSON.parse(Deno.readTextFileSync(path.join(RootPath, "/res/rooms.json"))); + this.#joinInProgressLookup = JSON.parse(Deno.readTextFileSync(path.join(RootPath, "/res/staticJoinInProgressLookup.json"))); this.server.Commands.addRootCommand(new Command({ key: ["rooms", "r", "room"], @@ -82,10 +92,17 @@ export class ServerRoomsBase extends ServerContentBase { })) } async #writeAgRooms() { - await this.kv.getKv().set([ServerRoomsBase.agRoomIdsKey], this.#agrooms); + await this.kv.getKv().set([ServerRoomsBase.agRoomIdsKey], this.#agroomIds); } async #writeBaseRooms() { - await this.kv.getKv().set([ServerRoomsBase.baseRoomIdsKey], this.#baserooms); + await this.kv.getKv().set([ServerRoomsBase.baseRoomIdsKey], this.#baseroomIds); + } + getAgRoomIds() { + return this.#agroomIds; + } + getAgRoomRuntimeConfig() { + if (!this.#agRoomRuntimeConfig) throw new Error("Config has not yet been initialized"); + return this.#agRoomRuntimeConfig; } async initBuiltinRooms(rooms: AGRoom[], locations: AGRoomLocation[]) { @@ -98,7 +115,10 @@ export class ServerRoomsBase extends ServerContentBase { "ARRoom", "Registration", "DormRoom" - ].includes(room.Name)) return; + ].includes(room.Name)) { + this.#log.w(`Room '${room.Name}' is not eligible for builtin room generation`); + return; + } const roomFactory = await this.write(); if (roomFactory == null) { @@ -118,7 +138,7 @@ export class ServerRoomsBase extends ServerContentBase { roomFactory.Description = room.Description; roomFactory.IsAGRoom = true; roomFactory.CloningAllowed = room.CloningAllowed; - roomFactory.ImageName = `${room.Name}.png` + roomFactory.ImageName = `${room.Name}.png`; const supportPromises: Promise[] = []; roomFactory.removeAllHardwareSupport(); @@ -134,7 +154,7 @@ export class ServerRoomsBase extends ServerContentBase { subroomFactory.RoomId = roomFactory.getRoomId(); subroomFactory.Name = scene.Name; - subroomFactory.RoomSceneLocationId = scene.RoomSceneLocationId; + subroomFactory.RoomSceneLocationId = scene.RoomSceneLocationId as RoomLocation; subroomFactory.IsSandbox = scene.IsSandbox; subroomFactory.CanMatchmakeInto = scene.CanMatchmakeInto; subroomFactory.MaxPlayers = scene.MaxPlayers; @@ -146,15 +166,33 @@ export class ServerRoomsBase extends ServerContentBase { await Promise.all(supportPromises); - this.#agrooms.add(roomFactory.getRoomId()); + this.#agroomIds.add(roomFactory.getRoomId()); await roomFactory.write(); - if (room.CloningAllowed) this.#baserooms.add(roomFactory.getRoomId()); + if (room.CloningAllowed) this.#baseroomIds.add(roomFactory.getRoomId()); })); await this.#writeAgRooms(); await this.#writeBaseRooms(); - this.#log.i(`${this.#agrooms.size} AG rooms added: [${this.#agrooms.values().toArray().join(',')}]`); + this.#log.i(`${this.#agroomIds.size} AG rooms added: [${this.#agroomIds.values().toArray().join(',')}]`); + } + + sceneSupportsJoinInProgress(roomFactory: RoomFactory, subroomFactory: SubroomFactory) { + const agRoomRuntimeConfig = this.server.Rooms.getAgRoomRuntimeConfig(); + + const builtinScene = agRoomRuntimeConfig.Rooms.find(room => room.Name === roomFactory.Name) + ?.Scenes.find(scene => scene.RoomSceneLocationId === subroomFactory.RoomSceneLocationId); + if (builtinScene) return builtinScene.SupportsJoinInProgress; + else { + if (!this.#joinInProgressLookup) throw new Error("JoinInProgress lookup table is not yet initialized"); + const lookup = this.#joinInProgressLookup[subroomFactory.RoomSceneLocationId]; + if (lookup) return lookup; + else return false; + } + } + + async getMany(...ids: number[]) { + return (await Promise.all(ids.map(id => this.get(id)))).filter(val => val !== null); } async getAvailableRoomId() { @@ -163,6 +201,10 @@ export class ServerRoomsBase extends ServerContentBase { return id; } + async getByRoomSceneId(id: number) { + return await new SubroomFactory(this.server, this.#subroomKv).init({ mode: FactoryMode.Fetch, writeMode: WriteMode.WriteIfFree, id }); + } + getKv() { return this.kv; } @@ -176,7 +218,7 @@ export class ServerRoomsBase extends ServerContentBase { async getPlayerDorm(profile: Profile) { const id = await this.kv.getKv().get([ServerRoomsBase.playerDormsKey, profile.getId()]); if (id.value == null) { - const roomFactory = await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Write, writeMode: WriteMode.WriteIfFree, id: await this.getAvailableRoomId() }); + const roomFactory = await new RoomFactory(this.server, this.kv, this.#subroomKv).init({ mode: FactoryMode.Write, writeMode: WriteMode.WriteIfFree, id: await this.getAvailableRoomId() }); if (!roomFactory) return null; roomFactory.setRoomProperties({ Name: `DormRoom`, @@ -216,7 +258,7 @@ export class ServerRoomsBase extends ServerContentBase { return roomFactory; } - else return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Fetch, id: id.value }); + else return await new RoomFactory(this.server, this.kv, this.#subroomKv).init({ mode: FactoryMode.Fetch, id: id.value }); } async getByName(name: string) { @@ -226,11 +268,11 @@ export class ServerRoomsBase extends ServerContentBase { } async get(id: number) { - return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Fetch, id: id }); + return await new RoomFactory(this.server, this.kv, this.#subroomKv).init({ mode: FactoryMode.Fetch, id: id }); } async write(mode?: WriteMode) { - return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: await this.getAvailableRoomId() }); + return await new RoomFactory(this.server, this.kv, this.#subroomKv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: await this.getAvailableRoomId() }); } } \ No newline at end of file diff --git a/src/server/rooms/internal/RoomDataTypes.ts b/src/server/rooms/internal/RoomDataTypes.ts index 76257b5..9ad3d1e 100644 --- a/src/server/rooms/internal/RoomDataTypes.ts +++ b/src/server/rooms/internal/RoomDataTypes.ts @@ -1,3 +1,5 @@ +import { RoomLocation } from "../../instances/types.ts"; + export enum WriteMode { Overwrite = "overwrite", WriteIfFree = "if_free" @@ -34,7 +36,7 @@ export enum RoomAccessibility { export interface RoomScene { RoomSceneId: number, RoomId: number, - RoomSceneLocationId: string, + RoomSceneLocationId: RoomLocation, Name: string, IsSandbox: boolean, DataBlobName: string, @@ -151,7 +153,7 @@ export interface DatabaseRoom { export interface SubroomProps { RoomId: number, - RoomSceneLocationId: string, + RoomSceneLocationId: RoomLocation, Name: string, IsSandbox: boolean, MaxPlayers: number, diff --git a/src/server/rooms/internal/RoomFactory.ts b/src/server/rooms/internal/RoomFactory.ts index cbb03f9..8e7c6db 100644 --- a/src/server/rooms/internal/RoomFactory.ts +++ b/src/server/rooms/internal/RoomFactory.ts @@ -29,6 +29,7 @@ export class RoomFactory { #server: ServerBase; #kv: KV; + #subroomKv: KV; #roomId: number | undefined; @@ -43,10 +44,11 @@ export class RoomFactory { #cannotAccessBeforeInitError = new Error("Cannot access properties before initialization"); #cannotWriteBeforeInitError = new Error("Cannot write before initialization"); - constructor(server: ServerBase, kv: KV) { + constructor(server: ServerBase, kv: KV, subroomKv: KV) { this.#server = server; this.#kv = kv; + this.#subroomKv = subroomKv; } @@ -161,9 +163,7 @@ export class RoomFactory { const autoTags = this.getTags(); const galvTags = this.getGalvanicTags(); - const subroomExports = (await Promise.all( - this.getSubrooms().values().map(subroom => this.getSubroom(subroom)) - )).map(factory => factory.export()); + const subroomExports = (await Promise.all((await this.getAllSubrooms()).values())).map(factory => factory.export()); return { Room: { @@ -208,22 +208,27 @@ export class RoomFactory { return this.#roomId; } - getSubrooms() { + getSubroomIds() { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; return this.#subrooms; } async getSubroom(id: number) { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; if (!this.#subrooms.has(id)) throw new Error("Subroom not available to this room"); - return await new SubroomFactory(this.#server, this.#kv).init({ mode: FactoryMode.Fetch, writeMode: WriteMode.WriteIfFree, id }); + return await new SubroomFactory(this.#server, this.#subroomKv).init({ mode: FactoryMode.Fetch, writeMode: WriteMode.WriteIfFree, id }); } - getAvailableSubroomId() { + async getAllSubrooms() { + return new Set(await Promise.all( + this.getSubroomIds().values().map(subroom => this.getSubroom(subroom)) + )); + } + async getAvailableSubroomId() { let id = Math.round(Math.random() * Math.pow(2, 31)); - if (this.getSubrooms().has(id)) id = this.getAvailableSubroomId(); + if ((await this.#subroomKv.getKv().get([id])).value) id = await this.getAvailableSubroomId(); return id; } async newSubroom(mode?: WriteMode) { - return await new SubroomFactory(this.#server, this.#kv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: this.getAvailableSubroomId() }); + return await new SubroomFactory(this.#server, this.#subroomKv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: await this.getAvailableSubroomId() }); } addSubroom(id: number) { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; diff --git a/src/server/rooms/internal/SubroomFactory.ts b/src/server/rooms/internal/SubroomFactory.ts index 8c076cb..9280310 100644 --- a/src/server/rooms/internal/SubroomFactory.ts +++ b/src/server/rooms/internal/SubroomFactory.ts @@ -2,6 +2,7 @@ import Logging from "@proxnet/undead-logging"; import type KV from "../../persistence/kv.ts"; import { type ServerBase } from "../../server.ts"; import { DatabaseSubroom, FactoryMode, RoomDataTypes, RoomSave, RoomSaveMap, WriteMode } from "./RoomDataTypes.ts"; +import { RoomLocation } from "../../instances/types.ts"; export interface SubroomFactoryOptions { mode: FactoryMode, @@ -44,7 +45,7 @@ export class SubroomFactory { this.#obj = options.mode == FactoryMode.Fetch ? data.value : { RoomId: 0, - RoomSceneLocationId: "", + RoomSceneLocationId: RoomLocation.MakerRoom, Name: "Subroom data init failed, contact an admin!", IsSandbox: false, MaxPlayers: 8, @@ -92,7 +93,7 @@ export class SubroomFactory { get RoomSceneId() { if (!this.#subroomId) throw this.#cannotAccessBeforeInitError; else return this.#subroomId; } - get RoomSceneLocationId() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.RoomSceneLocationId; } + get RoomSceneLocationId(): RoomLocation { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.RoomSceneLocationId; } set RoomSceneLocationId(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else this.#obj.RoomSceneLocationId = data } get Name() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Name; } diff --git a/src/server/server.ts b/src/server/server.ts index 7eb2e7a..6438306 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -3,11 +3,14 @@ import { ServerUpdateEvent } from "../serverevents.ts"; import { AvatarContentBase } from "./avatars/base.ts"; import { EventManager } from "./baseevent.ts"; import { CommandsBase } from "./commands/commands.ts"; +import { ServerConsumablesBase } from "./consumables/base.ts"; import { ServerContentManager } from "./content/base.ts"; import GameConfigsBase from "./gameconfigs/base.ts"; import { InstanceManager } from "./instances/base.ts"; +import { ServerMatchmakingBase } from "./matchmaking/base.ts"; import { Objective, ObjectiveType } from "./objectives/base.ts"; import { PlatformsManager } from "./platforms/base.ts"; +import { ServerPresenceBase } from "./presence/base.ts"; import { type PresenceUpdateEvent } from "./presence/events/PresenceUpdateEvent.ts"; import { type ProfileUpdateEvent } from "./profiles/events/ProfileUpdate.ts"; import { ProfileUpdatedSettingEvent } from "./profiles/events/ProfileUpdatedSetting.ts"; @@ -15,6 +18,7 @@ import { type RoleUpdateEvent } from "./profiles/events/RoleUpdate.ts"; import ProfileManagerBase from "./profiles/manager.ts"; import { ServerRoomsBase } from "./rooms/base.ts"; import { RoomUpdatedEvent, SubroomUpdatedEvent } from "./rooms/internal/RoomEvents.ts"; +import { AnnouncementDTO } from "./types.ts"; interface ServerEvents { 'profile.roleupdate': RoleUpdateEvent, @@ -42,7 +46,7 @@ interface AutoMicMutingConfig { MicSpamWarningStateVolumeMultiplier: number; }; -export type PublicConfig = { +export interface PublicConfig { ShareBaseUrl: string; ServerMaintenance: { StartsInMinutes: number; @@ -61,6 +65,9 @@ class ServerBase extends EventManager { Instances = new InstanceManager(this, 'instances'); Content = new ServerContentManager(this, "content"); Rooms = new ServerRoomsBase(this, 'rooms', true); + Matchmaking = new ServerMatchmakingBase(this, "match"); + Presence = new ServerPresenceBase(this, "pres"); + Consumables = new ServerConsumablesBase(this, "consumables"); generateMask(...num: number[]) { return num.reduce((sum, val) => sum + val, 0); @@ -137,6 +144,11 @@ class ServerBase extends EventManager { return conf; } + + getAnnouncements(): AnnouncementDTO[] { + return []; + } + } const Server = new ServerBase(); diff --git a/src/server/socket/console/socket.ts b/src/server/socket/console/socket.ts index f21ca33..b15b4c4 100644 --- a/src/server/socket/console/socket.ts +++ b/src/server/socket/console/socket.ts @@ -4,7 +4,8 @@ import { type ConsoleItem, ConsoleItemSchema } from "./zod.ts"; import Server from "../../server.ts"; import { getSourceAddress } from "../../../util/net.ts"; import { consoleSockets } from "../../../main.ts"; -import chalk from "npm:chalk@^5.3.0"; +import chalk from "chalk"; +import { CommandSenderType } from "../../commands/cmdtypes.ts"; export default class SocketConsoleHandler { @@ -55,7 +56,7 @@ export default class SocketConsoleHandler { if (!zodParsed.success) this.destroy(); else if (zodParsed.data.e == ConsoleEvent.Command) { - const data = await Server.Commands.dispatch(...zodParsed.data.d.split(' ')); + const data = await Server.Commands.dispatch({ type: CommandSenderType.Console }, ...zodParsed.data.d.split(' ')); if (data instanceof Error) throw data; this.send(ConsoleEvent.Message, chalk.gray(`> ${chalk.yellow(data)}`)); diff --git a/src/server/socket/signalr/socket.ts b/src/server/socket/signalr/socket.ts index b94c169..d73ae36 100644 --- a/src/server/socket/signalr/socket.ts +++ b/src/server/socket/signalr/socket.ts @@ -18,6 +18,8 @@ import { SocketTarget } from "./targets/targetbase.ts"; import type Profile from "../../profiles/profile.ts"; import { detailedLog } from "../../../main.ts"; import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; +import Server from "../../server.ts"; +import { PresenceUpdateEvent } from "../../presence/events/PresenceUpdateEvent.ts"; const logmessages = true; @@ -32,6 +34,10 @@ export class SignalRSocketHandler { #killed = false; + #presCb = (ev: PresenceUpdateEvent) => { + if (ev.profile == this.#profile) this.sendNotification("PresenceUpdate", ev.presence.export()); + } + constructor(socket: WebSocket, player: Profile) { this.#socket = socket; @@ -69,6 +75,8 @@ export class SignalRSocketHandler { } async #onMessage(message: Message) { + this.#profile.Matchmaking.updateLastSeen(); + if (message.kind == MessageKind.Protocol) { this.sendRaw({}); return; @@ -130,6 +138,8 @@ export class SignalRSocketHandler { } }); + Server.on('presence.update', this.#presCb); + this.#socket.addEventListener('close', this.destroy(this, true)); } @@ -137,10 +147,12 @@ export class SignalRSocketHandler { return (ev: CloseEvent) => { handler.#killed = true; + Server.off('presence.update', this.#presCb); + let errorReason = "Socket closed by server"; this.#log.d(`Socket close code: ${ev.code}`); if (ev.reason.includes('Bye!')) errorReason = "Socket closed by client request"; - + handler.sendRaw({ type: 7, error: errorReason }); if (!internal) handler.#socket.close(); diff --git a/src/server/socket/signalr/targets/SubscribeToPlayers.ts b/src/server/socket/signalr/targets/SubscribeToPlayers.ts index 73a1f72..b0b1906 100644 --- a/src/server/socket/signalr/targets/SubscribeToPlayers.ts +++ b/src/server/socket/signalr/targets/SubscribeToPlayers.ts @@ -6,7 +6,7 @@ export class PlayerSocketSubscriptionTarget extends SocketTarget { #ids: number[] = []; override zod = z.object({ - PlayerIds: z.array(z.number().nonnegative().max(2_147_483_647)) + PlayerIds: z.array(z.number().nonnegative().max(Math.pow(2, 31))) }); override exec(...ids: number[]) { diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..f1a6ea5 --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,29 @@ +import { PlatformType } from "./platforms/types.ts"; + +export enum AnnouncementType { + Update, + Contest, + Store, + Event, + Warning +} +enum AnnouncementLinkType { + Url, + AccountId, + EventId, + RoomName, + Storefront +} + +export interface AnnouncementDTO { + AnnouncementId: number, + AnnouncementType: AnnouncementType, + Title: string, + Body: string, + ImageName: string, + LinkType: AnnouncementLinkType, + LinkName: string, + LinkUri: string, + Platform: PlatformType, + CreatedAt: string, +} \ No newline at end of file diff --git a/src/util/api.ts b/src/util/api.ts index 380c702..3c98e6c 100644 --- a/src/util/api.ts +++ b/src/util/api.ts @@ -134,4 +134,42 @@ export class RateLimiter { #close() { clearInterval(this.#intervalId); } +} + +const loginLockBodySchema = z.object({ + LoginLock: z.uuidv4() +}); +export const loginLockMiddleware = async (c: Context, nxt: Next) => { + function unauthorized() { + return statusResponse(c, HTTPStatus.Unauthorized); + } + + if (c.req.header("Content-Type") !== "application/x-www-form-urlencoded") return unauthorized(); + try { + const form = await c.req.formData(); + + const body = await loginLockBodySchema.safeParseAsync(Object.fromEntries(form.entries())); + if (body.success) { + if (typeof c.get('profile') == 'undefined') { + log.w(`Profile was not set, cannot validate LoginLock. Was the request authorized?`); + return statusResponse(c, HTTPStatus.InternalServerError); + } + + const profile = c.get('profile'); + + const loginLock = await profile.Matchmaking.getLoginLock(); + if (!loginLock) await profile.Matchmaking.setLoginLock(body.data.LoginLock); + else if (body.data.LoginLock !== loginLock) { + log.w(`LoginLock did not match. The token for this profile could be compromised or the client is an unknown state.`); + return unauthorized(); + } + + return await nxt(); + } else { + log.w(`LoginLock parse failed: ${JSON.stringify(body.error)}`); + return unauthorized(); + } + } catch { + return unauthorized(); + } } \ No newline at end of file diff --git a/src/util/import.ts b/src/util/import.ts index 09d72bf..af25111 100644 --- a/src/util/import.ts +++ b/src/util/import.ts @@ -11,23 +11,23 @@ export async function routeImporter(hono: Hono, prefix: string, paths: for (const route of items) hono.route(route.path, route.app); } -export async function importer(importKey: string, prefix: string, paths: string[]): Promise { - const log = new Logging(`Importer:'${importKey}'-${prefix}`); +export async function importer(importKey: string, p: string, paths: string[]): Promise { + const log = new Logging(`Importer:'${importKey}':${p}`); const items: T[] = []; for (const pathStr of paths) { - const importPath = path.join(process.cwd(), prefix, pathStr); + const importPath = path.join(process.cwd(), p, pathStr); if (debug) log.d(`'${importKey}' found ${importPath}`); for await (const localPath of Deno.readDir(importPath)) { if (localPath.isDirectory) continue; if (localPath.isFile && localPath.name.endsWith('.ts')) { - const fullPath = path.join('file://', importPath, localPath.name); + const fullPath = path.join(importPath, localPath.name); if (debug) log.d(`'${importKey}' importing ${fullPath}`); - await import(fullPath).then(val => { + await import(`file://${fullPath}`).then(val => { if (val[importKey]) items.push(val[importKey]); else log.w(`Import key '${importKey}' not found on: '${fullPath}'`); diff --git a/src/util/validators.ts b/src/util/validators.ts index e7a08dc..19401ad 100644 --- a/src/util/validators.ts +++ b/src/util/validators.ts @@ -43,9 +43,25 @@ export const transformStringToEnum = (anEnum: { [s: string]: string | number export const transformCheckEnum = (anEnum: { [s: string]: string | number }) => { return (arg: number | string, ctx: z.RefinementCtx) => { - if (typeof anEnum[arg] == 'undefined') { - ctx.addIssue("Not an enum member"); + function invalid() { + ctx.addIssue("Invalid enum member"); + } + + if (typeof arg == 'string') { + if (typeof anEnum[arg] == 'number') return anEnum[arg] as T; + else { + invalid(); + return null; + } + } else if (typeof arg == 'number') { + if (typeof anEnum[arg] == 'string') return arg as T; + else { + invalid(); + return null; + } + } else { + invalid(); return null; - } else return anEnum[arg] as T; + } } } \ No newline at end of file