Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

inital sgf download #273

Merged
merged 7 commits into from
Jun 18, 2024
Merged

Conversation

SameerDalal
Copy link
Collaborator

Currently the SGF data for a quantum game is stored in the this.sgfContent variable in quantum.ts. For the user to download this, I also created a download button in DownloadSGF.vue, in the GameView folder, however I'm not sure how to move the contents of the this.sgfContent string from the typescript file to the vue file.

@benjaminpjones
Copy link
Collaborator

benjaminpjones commented Jun 12, 2024

This is a nice start!

I think the main part that is missing is the server endpoint. That will probably go in server/src/api.ts. games/:gameId is the most similar endpoint to what we'll need for SGF, except instead of a JSON response, we'll send a text file.

router.get('/games/:gameId/sgf', function(request, response){
  try {
    // get the moves and other game info from database
    const game: GameResponse = await getGame(req.params.gameId);
    // convert to sgf format
    const sgfString: string = toSGFString(game.moves, game.players);

    // pipes a file from memory to the client
    // mostly copied from
    // https://stackoverflow.com/questions/45922074/node-express-js-download-file-from-memory-filename-must-be-a-string
    var fileContents = Buffer.from(sgfString, "base64");
    var fileName = `${req.params.gameId}.sgf`
  
    var readStream = new stream.PassThrough();
    readStream.end(fileContents);

    response.set('Content-disposition', 'attachment; filename=' + fileName);
    response.set('Content-Type', 'text/plain');

    readStream.pipe(response);
  } catch (e) {
    // Express 4 doesn't handle async errors very well unfortunately, so we have to send the error manually
    res.status(500);
    res.json(e.message);
  }
});

Disclaimer: this is just me copying code around - I haven't tested myself. Let me know if you run into any issues with this!


I haven't referenced QuantumGo directly. This is because I noticed your current implementation could work reasonably well for other variants (except maybe the SGF header), so it would be a lot simpler to have the SGF implementation decoupled from the variant itself. If you foresee the implementation diverging from standard SGF/Go, we can figure out a way to let variants define their own SGF function, just let me know.

</script>

<template>
<button @click="downloadData">Download Game Files</button>
Copy link
Collaborator

@benjaminpjones benjaminpjones Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a downloadData callback, we can probably make this an <a href="/games/${gameId}/sgf">Download Game Files</a>

(Content-Disposition: attachment will instruct the browser to save the file)

</div>

<DownloadSGF />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably belongs on the GameView itself, not the NavButtons

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

@benjaminpjones
Copy link
Collaborator

benjaminpjones commented Jun 12, 2024

I haven't referenced QuantumGo directly. This is because I noticed your current implementation could work reasonably well for other variants (except maybe the SGF header), so it would be a lot simpler to have the SGF implementation decoupled from the variant itself. If you foresee the implementation diverging from standard SGF/Go, we can figure out a way to let variants define their own SGF function, just let me know.

On second thought, we probably do need a per-variant implementation, esp. since some variants don't just have black and white. So the way we would do that I think is to expose the SGF via a new optional member of AbstractGame:

class AbstractGame {
    ...
  sgfContent?: string;
}

Then use that in the express endpoint:

  // get the moves and other game info from database
  const game: GameResponse = await getGame(req.params.gameId);
  // When game.variant == "quantum", this creates an instance of QuantumGo
  const game_obj: AbstractGame = makeGameObject(game.variant, game.config);
  // play out the game
  game.moves.forEach((moves) => {
    const { player, move } = getOnlyMove(moves);
    game_obj.playMove(player, move);
  });
  // For backwards compatibility, variants don't have to define sgfContent
  if (game_obj.sgfContent === undefined) {
    throw new Error(`SGF not supported for variant ${game.variant}`)
  }
  const sgfString: string = game_obj.sgfContent;

<script setup lang="ts">
import { defineProps } from 'vue';

const props = defineProps<{
Copy link
Collaborator

@benjaminpjones benjaminpjones Jun 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a TS error in CI:

error TS2345: Argument of type '{}' is not assignable to parameter of type 'NonNullable<(Partial<{ readonly [x: number]: string; }> & Omit<(readonly string[] | { readonly [x: string]: Prop<unknown, unknown> | null | undefined; }) & (VNodeProps & AllowedComponentProps & (ComponentCustomProps & Readonly<...>)), never> & Record<...>) | (Partial<...> & ... 1 more ... & Record<...>)> & Record<...>'.

It's not entirely obvious from the error message, but it will be resolved once you add some non-empty props here. (you could also fix the error by removing defineProps(), but I think we'll want to pass in gameId at least)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

@merowin
Copy link
Collaborator

merowin commented Jun 12, 2024

Nice start, thank you for working on this!

Recently we made some changes that enable graph boards for certain variants, including quantum, see e.g. #264
For grid boards, the moves are stored represented as sgf coordinates, but not for non-rectangular boards.

To distinguish between the two cases, there is the function isGridBadukConfig

@SameerDalal
Copy link
Collaborator Author

SameerDalal commented Jun 12, 2024

I haven't referenced QuantumGo directly. This is because I noticed your current implementation could work reasonably well for other variants (except maybe the SGF header), so it would be a lot simpler to have the SGF implementation decoupled from the variant itself. If you foresee the implementation diverging from standard SGF/Go, we can figure out a way to let variants define their own SGF function, just let me know.

On second thought, we probably do need a per-variant implementation, esp. since some variants don't just have black and white. So the way we would do that I think is to expose the SGF via a new optional member of AbstractGame:

class AbstractGame {
    ...
  sgfContent?: string;
}

Then use that in the express endpoint:

  // get the moves and other game info from database
  const game: GameResponse = await getGame(req.params.gameId);
  // When game.variant == "quantum", this creates an instance of QuantumGo
  const game_obj: AbstractGame = makeGameObject(game.variant, game.config);
  // play out the game
  game.moves.forEach((moves) => {
    const { player, move } = getOnlyMove(moves);
    game_obj.playMove(player, move);
  });
  // For backwards compatibility, variants don't have to define sgfContent
  if (game_obj.sgfContent === undefined) {
    throw new Error(`SGF not supported for variant ${game.variant}`)
  }
  const sgfString: string = game_obj.sgfContent;

I used this implementation and created the proper API endpoints in api.ts and index.ts. Whenever the download button is clicked the link is changes to /game/{gameID}/sgf, which is correct. However in api.ts, the router.get function, which executes the download, is not being called. I assumed that the <a> tag would automatically create a GET request to the router, so I'm not sure why the code below is not being called.

api.ts

router.get('/game/:gameId/sgf', async (req, res) => {
  //route not being called
  console.log("test");
  res.status(200).send("test");
  try {

    const game = await getGame(req.params.gameId);

    const game_obj = makeGameObject(game.variant, game.config);

    game.moves.forEach((moves) => {
      const { player, move } = getOnlyMove(moves);
      game_obj.playMove(player, move);
    });

    if (game_obj.getSGF() === '') {
      throw new Error(`SGF not supported for variant ${game.variant}`);
    }
    
    // download sgf
    res.download(game_obj.getSGF());
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

DownloadSGF.vue



<script setup lang="ts">


const props = defineProps<{ gameId?: string}>();

</script>

<template>
  <a :href="`/game/${props.gameId}/sgf`">Download Game Files</a>
</template>
  
  <style scoped>
  button {
    margin-top: 10px;
  }
  </style>

@benjaminpjones
Copy link
Collaborator

benjaminpjones commented Jun 12, 2024

Whenever the download button is clicked the link is changes to /game/{gameID}/sgf, which is correct. However in api.ts, the router.get function, which executes the download, is not being called.

Ah, I forgot - we prepend /api to all the routes in api.ts. If you do /api/game/{gameID}/sgf, the route will be called. One way to check server endpoints without client is to visit localhost:3001 directly (in this case localhost:3001/api/game/:id/sgf) - that's how I knew it was a problem with the URL and not the <a> tag.


There is another issue once you get that fixed. res.download() takes a filepath, not the file content, which is why I recommend trying the pipe stuff from this SO thread:

Edit: I had some "stream" stuff here before, but I found it can be much simpler:

    // download sgf
    res.set({
      "Content-Disposition": `attachment; filename="${req.params.gameId}.sgf"`,
    });
    res.set("Content-Type", "text/plain");
    res.send(game_obj.getSGF());

@SameerDalal
Copy link
Collaborator Author

Thank you for your help! The download SGF feature should be complete and is ready to be merged into the main branch.

@SameerDalal SameerDalal reopened this Jun 17, 2024
@@ -182,6 +182,8 @@ test("Two passes ends the game", () => {

game.playMove(0, "pass");
game.playMove(1, "pass");

expect(game.getSGF()).toBe("(;\nEV[GO Variants]\nPB[player Black]\nPW[player white]\nSZ[4:2]\nKM[6.5]\nVS[quantum]\n\n\n;B[ba];W[ca];B[bb];W[cb]\n\n)");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I think I understand your comment on the other PR now

Are there any benefits to creating a more complex test?

I had written a standalone test, which has some benefits, but this is fine too :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect!

throw new Error(`SGF not supported for variant ${game.variant}`);
} else if (game_obj.getSGF() === "non-rectangular"){
throw new Error('SGF for non rectangular boards is not possible');
} else if (!(game_obj.phase === 'gameover')){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my own understanding is there something about SGF that requires the game to be finished? This is totally fine regardless, just wondering if we should consider implementing mid-game sgf download in the future.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could definitely implement a mid-game sgf download if needed; it wouldn't require that much modification.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could definitely implement a mid-game sgf download if needed; it wouldn't require that much modification.

We could definitely implement a mid-game sgf download if needed; it wouldn't require that much modification.

Now that I think about that, our current implementation relies on the game to be over before it saves the entire sgf. It is still possible but would require a different approach

@benjaminpjones
Copy link
Collaborator

Nice work, thank you Sameer! I'll test a bit then merge later tonight or tomorrow

@SameerDalal
Copy link
Collaborator Author

Nice work, thank you Sameer! I'll test a bit then merge later tonight or tomorrow

No problem! I'll do one more commit with the updated yarn lock!

@SameerDalal
Copy link
Collaborator Author

Nice work, thank you Sameer! I'll test a bit then merge later tonight or tomorrow

No problem! I'll do one more commit with the updated yarn lock!

Nice work, thank you Sameer! I'll test a bit then merge later tonight or tomorrow

No problem! I'll do one more commit with the updated yarn lock!

The yarn lock is good as is!

@benjaminpjones
Copy link
Collaborator

When merging, I noticed lints and tests failed. Here are the commands to run them locally:

yarn lint
yarn test

I have a precommit hook set up to run these before every commit, so I don't have to think about it. Pretty easy to set up - just add a file named .git/hooks/pre-commit with the following:

yarn lint:check
yarn test

@benjaminpjones benjaminpjones merged commit f060f4d into govariantsteam:main Jun 18, 2024
1 check passed
@benjaminpjones
Copy link
Collaborator

Changes are live!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants