Loading save files done.

This commit is contained in:
2025-03-11 19:03:11 -05:00
parent 85f643ef10
commit 60db15e932
19 changed files with 489 additions and 76 deletions

View File

@ -9,6 +9,8 @@
},
"dependencies": {
"@apollo/client": "^3.13.4",
"@graphql-tools/merge": "^9.0.22",
"@graphql-tools/schema": "^10.0.21",
"apollo-server-micro": "^3.13.0",
"graphql": "^16.10.0",
"micro": "^10.0.1",

View File

@ -63,12 +63,16 @@
}
EJS_defaultOptions = {
};
const emulatorInit = data => {
EJS_gameUrl = `/api/v1/rom?id=${data.gameId}`;
EJS_core = data.core;
EJS_gameName = data.gameName;
EJS_externalFiles = {
'/homeplay/saves/': `/api/v1/save?id=${data.gameId}`
}
scriptElement = document.createElement('script');
scriptElement.src = 'https://cdn.emulatorjs.org/stable/data/loader.js';
@ -103,6 +107,31 @@
};
EJS_ready = () => {
// Modify the EJS stuff
const oldGetRetroArchCfg = window.EJS_GameManager.prototype.getRetroArchCfg;
const oldMountFileSystems = window.EJS_GameManager.prototype.mountFileSystems;
EJS_GameManager.prototype.getRetroArchCfg = function() {
const ejsConfig = oldGetRetroArchCfg.call(this);
// Parse back into key value pair
const raConfig = ejsConfig.split('\n').reduce((acc, line) => {
if(!line.length) return acc;
const [key, value] = line.split('=');
acc[key.trim()] = value.trim();
return acc;
}, {});
// Add new options
raConfig['savefiles_in_content_dir'] = false;
raConfig['sort_savefiles_by_content_enable'] = false;
raConfig['sort_savefiles_enable'] = false;
raConfig['savefile_directory'] = '/homeplay/saves';
// Return back as RA config string
return Object.entries(raConfig).map(([k, v]) => `${k} = ${v}`).join('\n');
}
send({ message: 'ready' });
}

View File

@ -1,18 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import styles from './Emulator.module.scss';
type EmulatorSystem = (
'gb' | 'gbc' | 'gba'
);
type EmulatorCore = (
'gambatte' | 'mgba'
);
import { GAME_SYSTEM_CORES, GameSystem, GameSystemCore } from '@/lib/game';
type SendEmulatorMessageInit = {
message:'init';
core:EmulatorCore;
system:EmulatorSystem;
core:GameSystemCore;
system:GameSystem;
gameId:string;
gameName:string;
}
@ -35,13 +28,9 @@ type ReceiveEmulatorMessage = (
);
export type EmulatorProps = {
system:EmulatorSystem;
};
const EMULATOR_SYSTEM_CORES:{ [key in EmulatorSystem]:EmulatorCore } = {
'gb': 'gambatte',
'gbc': 'gambatte',
'gba': 'mgba'
system:GameSystem;
gameId:string;
gameName:string;
};
export const Emulator:React.FC<EmulatorProps> = props => {
@ -58,13 +47,13 @@ export const Emulator:React.FC<EmulatorProps> = props => {
send({
message: 'init',
core: EMULATOR_SYSTEM_CORES[props.system],
core: GAME_SYSTEM_CORES[props.system],
system: props.system,
gameId: '0',
gameName: 'Pokemon - Crystal Version'
gameId: props.gameId,
gameName: props.gameName
});
window.onmessage = (e) => {
window.onmessage = e => {
if(!e.data) {
console.error('Invalid message received:', e.data);
return;

77
src/graphql/game.ts Normal file
View File

@ -0,0 +1,77 @@
import { GAME_SYSTEMS } from "@/lib/game";
import { ApolloError, gql } from "apollo-server-micro";
import { pageTypeDefs } from "./page";
export const gameTypeDefs = gql`
${pageTypeDefs}
enum GameSystem {
${GAME_SYSTEMS.join('\n')}
}
type Game {
id: ID!
name: String!
system: GameSystem!
}
input GameFilter {
system: GameSystem
}
enum GameOrderBy {
NAME_ASC
NAME_DESC
}
type GameEdge {
node: Game
cursor: String
}
type GameConnection {
edges: [GameEdge]
pageInfo: PageInfo
}
type Query {
game(id: ID!): Game
games(first: Int!, after: String, orderBy: GameOrderBy, filter: GameFilter): GameConnection
}
`;
const GAME = {
id: '0',
name: 'Pokemon Crystal Version',
system: 'gbc'
};
export const gameResolvers = {
Query: {
game: (_:any, { id }:{ id:string }) => {
if(!id) throw new ApolloError('Invalid ID');
switch(id) {
case '0':
return GAME;
default:
return null;
}
},
games: (_: any, { first, after, orderBy, filter }: { first: number, after?: string, orderBy?: string, filter?: { system?: string } }) => {
if(first > 100) throw new ApolloError('first cannot be greater than 100');
return {
edges: [
{ node: GAME }
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '0'
},
};
},
},
};

10
src/graphql/page.ts Normal file
View File

@ -0,0 +1,10 @@
import { gql } from "apollo-server-micro";
export const pageTypeDefs = gql`
type PageInfo {
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
}
`;

View File

@ -1,13 +1,17 @@
import { gql } from 'apollo-server-micro';
import { gameTypeDefs, gameResolvers } from './game';
import { mergeTypeDefs } from '@graphql-tools/merge';
import { makeExecutableSchema } from '@graphql-tools/schema';
export const typeDefs = gql`
type Query {
hello: String
}
`;
export const typeDefs = mergeTypeDefs([
gameTypeDefs
]);
export const resolvers = {
Query: {
hello: () => 'Very cool!',
},
};
export const resolvers = [
gameResolvers
];
export const schema = makeExecutableSchema({
typeDefs,
resolvers
});

29
src/lib/fragment.ts Normal file
View File

@ -0,0 +1,29 @@
import { gql } from "apollo-server-micro";
import { DocumentNode } from "graphql";
type FragmentDefinition<T> = {
query:DocumentNode;
name:string;
needsInclude:boolean;
}
export const createFragment = <T>(params:{
query:DocumentNode;
name:string;
needsInclude?:boolean;
}):FragmentDefinition<T> => {
return {
...params,
needsInclude: typeof params.needsInclude === 'undefined' ? true : params.needsInclude
}
}
export const includeFragment = <T>(fragment:FragmentDefinition<T>) => {
if(!fragment.needsInclude) return '';
return fragment.query;
}
export const extendFragment = <T>(fragment:FragmentDefinition<T>) => {
if(!fragment.needsInclude) return fragment.query;
return `...${fragment.name}`;
}

45
src/lib/game.ts Normal file
View File

@ -0,0 +1,45 @@
import { createFragment } from "@/lib/fragment";
import { gql } from "apollo-server-micro";
export const GAME_SYSTEM_CORES = <const>{
'gb': 'gambatte',
'gbc': 'gambatte',
'gba': 'mgba'
};
export type GameSystem = keyof typeof GAME_SYSTEM_CORES;
export type GameSystemCore = typeof GAME_SYSTEM_CORES[GameSystem];
export type GameLight = {
id:string;
name:string;
system:GameSystem;
};
export const GameLightFragment = createFragment<GameLight>({
name: `GameLight`,
query: gql`
fragment GameLight on Game {
id
name
system
}
`
});
export type GameHeavy = {
id:string;
name:string;
system:GameSystem;
};
export const GameHeavyFragment = createFragment<GameHeavy>({
name: `GameHeavy`,
query: gql`
fragment GameHeavy on Game {
id
name
system
}
`
});

30
src/lib/page.ts Normal file
View File

@ -0,0 +1,30 @@
import { gql } from "apollo-server-micro";
import { DocumentNode } from "graphql";
export type Paginated<T> = {
edges: { node: T }[];
pageInfo:{
startCursor:string|null;
endCursor:string|null;
hasNextPage:boolean;
hasPreviousPage:boolean;
};
};
export const paginationQuery = (nodeDefs:DocumentNode|string) => {
return `
edges {
cursor
node {
${nodeDefs}
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
`;
};

13
src/pages/404.tsx Normal file
View File

@ -0,0 +1,13 @@
export type Error404Props = {
error:404;
};
export const Error404:React.FC<Error404Props> = props => {
return (
<div>
<h1>Error 404</h1>
</div>
);
}
export default Error404;

14
src/pages/500.tsx Normal file
View File

@ -0,0 +1,14 @@
export type Error500Props = {
code:500;
};
export const Error500:React.FC<Error500Props> = props => {
console.log('props', props);
return (
<div>
<h1>Error 500</h1>
</div>
)
}
export default Error500;

View File

@ -1,8 +1,8 @@
import { typeDefs, resolvers } from "@/graphql/schema";
import { typeDefs, schema } from "@/graphql/schema";
import { ApolloServer } from "apollo-server-micro";
import { NextApiRequest, NextApiResponse } from "next";
const apolloServer = new ApolloServer({ typeDefs, resolvers });
const apolloServer = new ApolloServer({ typeDefs, schema });
export const config = {
api: {

44
src/pages/api/v1/save.ts Normal file
View File

@ -0,0 +1,44 @@
import { NextApiHandler } from "next";
import * as fs from 'fs';
import * as path from 'path';
const PATH_SAVES = path.resolve('.', 'data', 'saves');
const handler:NextApiHandler = async (req, res) => {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
res.status(405).end(`Method Not Allowed`);
return;
}
// ID query param
if(!req.query.id || typeof req.query.id !== 'string') {
res.status(404).end(`Not Found`);
return;
}
const { id } = req.query;
if(!id) {
res.status(404).end(`Not Found`);
return;
}
// Does rom exist?
const pathSave = path.resolve(PATH_SAVES, `${id}.zip`);
if(!fs.existsSync(pathSave)) {
res.status(404).end(`Not Found`);
return;
}
// Read save
try {
const romStream = fs.createReadStream(pathSave);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${id}.zip"`);
romStream.pipe(res);
} catch (error) {
res.status(500).end(`Internal Server Error`);
}
};
export default handler;

View File

@ -1,4 +1,9 @@
import { GetServerSideProps, GetStaticPaths, GetStaticProps } from 'next';
import { apiClientGet } from '@/lib/apiClient';
import { includeFragment, extendFragment } from '@/lib/fragment';
import { GameHeavy, GameLightFragment } from '@/lib/game';
import { Error500, Error500Props } from '@/pages/500';
import { gql } from 'apollo-server-micro';
import { GetServerSideProps } from 'next';
import Link from 'next/link';
type PageParams = {
@ -6,31 +11,48 @@ type PageParams = {
}
type PageProps = {
id:string;
}
game:GameHeavy;
} | Error500Props;
export const getServerSideProps:GetServerSideProps<> = async ({ params }) => {
const { id } = params as PageParams;
export const getServerSideProps:GetServerSideProps<PageProps, PageParams> = async ({ params }) => {
try {
if(!params || !params.id) return { notFound: true };
// if(id !== '0') {
// return {
// notFound: true
// };
// }
const client = await apiClientGet();
const res = await client.query<{ game:GameHeavy }>({
variables: { id: params.id },
query: gql`
${includeFragment(GameLightFragment)}
return {
props: {
id
},
};
query getGame($id: ID!) {
game(id: $id) {
${extendFragment(GameLightFragment)}
}
}
`,
});
if(!res.data.game) return { notFound: true };
return { props: { ...res.data } };
} catch(e) {
console.error('Error', e);
return { props: { code: 500 } };
}
};
export const Page:React.FC<PageProps> = ({ id }) => {
export const Page:React.FC<PageProps> = props => {
if('code' in props) {
if(props.code === 500) return <Error500 {...props} />;
return null;
}
const { game } = props;
return (
<div>
<h1>Viewing Game ID: {id}</h1>
<h1>{ game.name }</h1>
<Link href={`/games/${id}/play`}>
<Link href={`/games/${game.id}/play`}>
Play Game
</Link>
</div>

View File

@ -27,7 +27,9 @@ export const Page:React.FC<PageProps> = ({ id }) => {
<div className={styles.play__emulator}>
<Emulator
system='gbc'
system="gbc"
gameId="0"
gameName="Pokemon Crystal Version"
/>
</div>
</div>

View File

@ -1,25 +1,73 @@
import { GetServerSideProps, GetStaticPaths, GetStaticProps } from 'next';
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { apiClientGet } from '@/lib/apiClient';
import { gql } from 'apollo-server-micro';
import { GameLight, GameLightFragment } from '@/lib/game';
import Error500, { Error500Props } from '../500';
import { extendFragment, includeFragment } from '@/lib/fragment';
import { Paginated, paginationQuery } from '@/lib/page';
type PageProps = {
}
export const getServerSideProps:GetServerSideProps = async ({ params }) => {
return {
props: {
},
};
type Game = {
id: string;
name: string;
system: string;
};
export const Page:React.FC<PageProps> = ({ }) => {
type PageProps = {
games:Paginated<GameLight>|null;
} | Error500Props;
export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
try {
const client = await apiClientGet();
const res = await client.query<{ games:Paginated<GameLight> }>({
variables: { },
query: gql`
${includeFragment(GameLightFragment)}
query getGames {
games(first: 100) {
${paginationQuery(extendFragment(GameLightFragment))}
}
}
`,
});
if(!res || !res.data || !res.data.games) {
return { props: { games:null } };
}
return { props: { games: res.data.games } };
} catch (e) {
console.error('Error', e);
return { props: { code: 500 } };
}
};
export const Page: React.FC<PageProps> = props => {
if ('code' in props) {
if (props.code === 500) return <Error500 {...props} />;
return null;
}
const { games } = props;
if(!games) {
return <div>No games found</div>;
}
return (
<div>
<h1>Games</h1>
<div>
<Link href="/games/0">
Test Game
<ul>
{games.edges.map(({ node }) => {
return (
<li key={node.id}>
<Link href={`/games/${node.id}`}>
{node.name} ({node.system})
</Link>
</div>
</li>
);
})}
</ul>
</div>
);
};

View File

@ -4,9 +4,11 @@ import { GetServerSideProps } from 'next';
import { apiClientGet } from '@/lib/apiClient';
import { ApolloQueryResult, gql } from '@apollo/client';
import Link from 'next/link';
import { GameLightFragment, GameLight } from '@/lib/game';
import { extendFragment, includeFragment } from '@/lib/fragment';
type PageData = {
hello:string;
game:GameLight;
}
type PageProps = ApolloQueryResult<PageData>
@ -16,10 +18,17 @@ export const getServerSideProps:GetServerSideProps<PageProps> = async () => {
const res = await client.query<PageData>({
query: gql`
query {
hello
${includeFragment(GameLightFragment)}
query getGame($id: ID!) {
game(id: $id) {
${extendFragment(GameLightFragment)}
}
}
`,
variables: {
id: '0'
}
`
});
return {
@ -34,11 +43,10 @@ const HomePage: React.FC<PageProps> = props => {
<title>HomePlay, your personal video game library</title>
</Head>
<Link href="/game/1/play">
Play Game
<Link href="/games">
Games
</Link>
{ props.data?.hello }
{ JSON.stringify(props) }
</div>
);
}

0
src/queries/game.ts Normal file
View File

View File

@ -152,6 +152,14 @@
"@graphql-tools/utils" "^9.2.1"
tslib "^2.4.0"
"@graphql-tools/merge@^9.0.22":
version "9.0.22"
resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-9.0.22.tgz#f8d316604360021c71d8a9397784f4eb178741fe"
integrity sha512-bjOs9DlTbo1Yz2UzQcJ78Dn9/pKyY2zNaoqNLfRTTSkO56QFkvqhfjQuqJcqu+V3rtaB2o0VMpWaY6JT8ZTvQA==
dependencies:
"@graphql-tools/utils" "^10.8.4"
tslib "^2.4.0"
"@graphql-tools/mock@^8.1.2":
version "8.7.20"
resolved "https://registry.yarnpkg.com/@graphql-tools/mock/-/mock-8.7.20.tgz#c83ae0f1940d194a3982120c9c85f3ac6b4f7f20"
@ -162,6 +170,15 @@
fast-json-stable-stringify "^2.1.0"
tslib "^2.4.0"
"@graphql-tools/schema@^10.0.21":
version "10.0.21"
resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-10.0.21.tgz#197584222c2fba507dfacf26dce96b0b1f67b746"
integrity sha512-AECSlNnD0WNxICwfJs93gYn2oHxPmztn1MYBETIQXrJJcymfD6BoUrDlYPa6F27RzRc+gbPZPHMWL26uujfKBg==
dependencies:
"@graphql-tools/merge" "^9.0.22"
"@graphql-tools/utils" "^10.8.4"
tslib "^2.4.0"
"@graphql-tools/schema@^8.0.0":
version "8.5.1"
resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.5.1.tgz#c2f2ff1448380919a330312399c9471db2580b58"
@ -189,6 +206,17 @@
dependencies:
tslib "^2.4.0"
"@graphql-tools/utils@^10.8.4":
version "10.8.4"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.8.4.tgz#59dc5ea949e26c6bae28b91ad9f5c00e992de072"
integrity sha512-HpHBgcmLIE79jWk1v5Bm0Eb8MaPiwSJT/Iy5xIJ+GMe7yAKpCYrbjf7wb+UMDMkLkfEryvo3syCx8k+TMAZ9bA==
dependencies:
"@graphql-typed-document-node/core" "^3.1.1"
"@whatwg-node/promise-helpers" "^1.0.0"
cross-inspect "1.0.1"
dset "^3.1.4"
tslib "^2.4.0"
"@graphql-tools/utils@^9.2.1":
version "9.2.1"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57"
@ -568,6 +596,13 @@
dependencies:
csstype "^3.0.2"
"@whatwg-node/promise-helpers@^1.0.0":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@whatwg-node/promise-helpers/-/promise-helpers-1.2.4.tgz#4f62d48e27059e8e655add21faa82d177abf5a0c"
integrity sha512-daEUfaHbaMuAcor+FPAVK+pOCSzsAYhK6LN1y81EcakdqQEPQvjm74PTmfwfv8POg8pw4RyCv9LXB1e+mQDwqg==
dependencies:
tslib "^2.6.3"
"@wry/caches@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@wry/caches/-/caches-1.0.1.tgz#8641fd3b6e09230b86ce8b93558d44cf1ece7e52"
@ -765,6 +800,13 @@ content-type@1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
cross-inspect@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cross-inspect/-/cross-inspect-1.0.1.tgz#15f6f65e4ca963cf4cc1a2b5fef18f6ca328712b"
integrity sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==
dependencies:
tslib "^2.4.0"
cssfilter@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae"
@ -790,6 +832,11 @@ detect-libc@^2.0.3:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
dset@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248"
integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==
fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@ -1205,7 +1252,7 @@ ts-invariant@^0.10.3:
dependencies:
tslib "^2.1.0"
tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0:
tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.3, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==