Compare commits

..

17 commits

Author SHA1 Message Date
Zutatensuppe
68a267bd70 only watch build dir 2021-10-10 12:09:50 +02:00
Zutatensuppe
bf4897bf83 fix type hint 2021-07-16 00:05:50 +02:00
Zutatensuppe
b4980e367c fix wording 2021-07-15 23:59:25 +02:00
Zutatensuppe
e7f86b5ef8 cleanup 2021-07-15 23:10:27 +02:00
Zutatensuppe
4e528cc83d store games in db 2021-07-12 01:28:14 +02:00
Zutatensuppe
126384e5bd upper case first letter of title 2021-07-11 22:56:52 +02:00
Zutatensuppe
e5fb49ecb1 build 2021-07-11 21:14:25 +02:00
Zutatensuppe
c11229a5e5 fix info overlay 2021-07-11 21:11:13 +02:00
Zutatensuppe
1008106355 build 2021-07-11 18:49:38 +02:00
Zutatensuppe
65daeb0247 only let uploader edit image 2021-07-11 18:44:59 +02:00
Zutatensuppe
d2d5968d02 send second id (client secret) as well with request 2021-07-11 17:56:04 +02:00
Zutatensuppe
8f31a669d5 send client id header with every request initiated from frontend to backend 2021-07-11 17:48:49 +02:00
Zutatensuppe
e7628895c9 sort finished games by finish date 2021-07-11 17:21:41 +02:00
Zutatensuppe
bbcfd42008 extract event adapter to own file 2021-07-11 17:08:18 +02:00
Zutatensuppe
518092d269 info overlay + script to update images in games and logs 2021-07-11 16:37:34 +02:00
Zutatensuppe
0cb1cec210 type hints 2021-07-09 01:19:35 +02:00
Zutatensuppe
2fb0e959ae add info layer that shows info about current puzzle 2021-07-09 01:17:26 +02:00
36 changed files with 988 additions and 429 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>🧩 jigsaw.hyottoko.club</title> <title>🧩 jigsaw.hyottoko.club</title>
<script type="module" crossorigin src="/assets/index.19dfb063.js"></script> <script type="module" crossorigin src="/assets/index.63ff8630.js"></script>
<link rel="modulepreload" href="/assets/vendor.684f7bc8.js"> <link rel="modulepreload" href="/assets/vendor.684f7bc8.js">
<link rel="stylesheet" href="/assets/index.22dc307c.css"> <link rel="stylesheet" href="/assets/index.22dc307c.css">
</head> </head>

View file

@ -155,6 +155,7 @@ function encodeGame(data) {
data.scoreMode, data.scoreMode,
data.shapeMode, data.shapeMode,
data.snapMode, data.snapMode,
data.creatorUserId,
]; ];
} }
function decodeGame(data) { function decodeGame(data) {
@ -170,6 +171,7 @@ function decodeGame(data) {
scoreMode: data[6], scoreMode: data[6],
shapeMode: data[7], shapeMode: data[7],
snapMode: data[8], snapMode: data[8],
creatorUserId: data[9],
}; };
} }
function coordByPieceIdx(info, pieceIdx) { function coordByPieceIdx(info, pieceIdx) {
@ -340,6 +342,8 @@ const INPUT_EV_REPLAY_SPEED_UP = 13;
const INPUT_EV_REPLAY_SPEED_DOWN = 14; const INPUT_EV_REPLAY_SPEED_DOWN = 14;
const INPUT_EV_TOGGLE_PLAYER_NAMES = 15; const INPUT_EV_TOGGLE_PLAYER_NAMES = 15;
const INPUT_EV_CENTER_FIT_PUZZLE = 16; const INPUT_EV_CENTER_FIT_PUZZLE = 16;
const INPUT_EV_TOGGLE_FIXED_PIECES = 17;
const INPUT_EV_TOGGLE_LOOSE_PIECES = 18;
const CHANGE_DATA = 1; const CHANGE_DATA = 1;
const CHANGE_TILE = 2; const CHANGE_TILE = 2;
const CHANGE_PLAYER = 3; const CHANGE_PLAYER = 3;
@ -368,6 +372,8 @@ var Protocol = {
INPUT_EV_REPLAY_SPEED_DOWN, INPUT_EV_REPLAY_SPEED_DOWN,
INPUT_EV_TOGGLE_PLAYER_NAMES, INPUT_EV_TOGGLE_PLAYER_NAMES,
INPUT_EV_CENTER_FIT_PUZZLE, INPUT_EV_CENTER_FIT_PUZZLE,
INPUT_EV_TOGGLE_FIXED_PIECES,
INPUT_EV_TOGGLE_LOOSE_PIECES,
CHANGE_DATA, CHANGE_DATA,
CHANGE_TILE, CHANGE_TILE,
CHANGE_PLAYER, CHANGE_PLAYER,
@ -606,12 +612,16 @@ function setEvtInfo(gameId, playerId, evtInfo) {
} }
function getAllGames() { function getAllGames() {
return Object.values(GAMES).sort((a, b) => { return Object.values(GAMES).sort((a, b) => {
const finished = isFinished(a.id);
// when both have same finished state, sort by started // when both have same finished state, sort by started
if (isFinished(a.id) === isFinished(b.id)) { if (finished === isFinished(b.id)) {
if (finished) {
return b.puzzle.data.finished - a.puzzle.data.finished;
}
return b.puzzle.data.started - a.puzzle.data.started; return b.puzzle.data.started - a.puzzle.data.started;
} }
// otherwise, sort: unfinished, finished // otherwise, sort: unfinished, finished
return isFinished(a.id) ? 1 : -1; return finished ? 1 : -1;
}); });
} }
function getAllPlayers(gameId) { function getAllPlayers(gameId) {
@ -626,10 +636,12 @@ function getPieceCount(gameId) {
return GAMES[gameId].puzzle.tiles.length; return GAMES[gameId].puzzle.tiles.length;
} }
function getImageUrl(gameId) { function getImageUrl(gameId) {
return GAMES[gameId].puzzle.info.imageUrl; const imageUrl = GAMES[gameId].puzzle.info.image?.url
} || GAMES[gameId].puzzle.info.imageUrl;
function setImageUrl(gameId, imageUrl) { if (!imageUrl) {
GAMES[gameId].puzzle.info.imageUrl = imageUrl; throw new Error('[2021-07-11] no image url set');
}
return imageUrl;
} }
function getScoreMode(gameId) { function getScoreMode(gameId) {
return GAMES[gameId].scoreMode; return GAMES[gameId].scoreMode;
@ -1243,7 +1255,6 @@ var GameCommon = {
getFinishedPiecesCount, getFinishedPiecesCount,
getPieceCount, getPieceCount,
getImageUrl, getImageUrl,
setImageUrl,
get: get$1, get: get$1,
getAllGames, getAllGames,
getPlayerBgColor, getPlayerBgColor,
@ -1349,6 +1360,7 @@ const get = (gameId, offset = 0) => {
log[0][5] = DefaultScoreMode(log[0][5]); log[0][5] = DefaultScoreMode(log[0][5]);
log[0][6] = DefaultShapeMode(log[0][6]); log[0][6] = DefaultShapeMode(log[0][6]);
log[0][7] = DefaultSnapMode(log[0][7]); log[0][7] = DefaultSnapMode(log[0][7]);
log[0][8] = log[0][8] || null;
} }
return log; return log;
}; };
@ -1358,6 +1370,8 @@ var GameLog = {
exists, exists,
log: _log, log: _log,
get, get,
filename,
idxname,
}; };
const log$4 = logger('Images.ts'); const log$4 = logger('Images.ts');
@ -1432,8 +1446,8 @@ const imageFromDb = (db, imageId) => {
const i = db.get('images', { id: imageId }); const i = db.get('images', { id: imageId });
return { return {
id: i.id, id: i.id,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id), tags: getTags(db, i.id),
@ -1471,8 +1485,8 @@ inner join images i on i.id = ixc.image_id ${where.sql};
const images = db.getMany('images', wheresRaw, orderByMap[orderBy]); const images = db.getMany('images', wheresRaw, orderByMap[orderBy]);
return images.map(i => ({ return images.map(i => ({
id: i.id, id: i.id,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id), tags: getTags(db, i.id),
@ -1489,8 +1503,8 @@ const allImagesFromDisk = (tags, sort) => {
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/)) .filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({ .map(f => ({
id: 0, id: 0,
uploaderUserId: null,
filename: f, filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`, url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: f.replace(/\.[a-z]+$/, ''), title: f.replace(/\.[a-z]+$/, ''),
tags: [], tags: [],
@ -1501,12 +1515,12 @@ const allImagesFromDisk = (tags, sort) => {
switch (sort) { switch (sort) {
case 'alpha_asc': case 'alpha_asc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file > b.file ? 1 : -1; return a.filename > b.filename ? 1 : -1;
}); });
break; break;
case 'alpha_desc': case 'alpha_desc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file < b.file ? 1 : -1; return a.filename < b.filename ? 1 : -1;
}); });
break; break;
case 'date_asc': case 'date_asc':
@ -1552,7 +1566,7 @@ var Images = {
// final resized version of the puzzle image // final resized version of the puzzle image
const TILE_SIZE = 64; const TILE_SIZE = 64;
async function createPuzzle(rng, targetTiles, image, ts, shapeMode) { async function createPuzzle(rng, targetTiles, image, ts, shapeMode) {
const imagePath = image.file; const imagePath = `${UPLOAD_DIR}/${image.filename}`;
const imageUrl = image.url; const imageUrl = image.url;
// determine puzzle information from the image dimensions // determine puzzle information from the image dimensions
const dim = await Images.getDimensions(imagePath); const dim = await Images.getDimensions(imagePath);
@ -1651,6 +1665,7 @@ async function createPuzzle(rng, targetTiles, image, ts, shapeMode) {
// information that was used to create the puzzle // information that was used to create the puzzle
targetTiles: targetTiles, targetTiles: targetTiles,
imageUrl, imageUrl,
image: image,
width: info.width, width: info.width,
height: info.height, height: info.height,
tileSize: info.tileSize, tileSize: info.tileSize,
@ -1741,7 +1756,63 @@ function setDirty(gameId) {
function setClean(gameId) { function setClean(gameId) {
delete dirtyGames[gameId]; delete dirtyGames[gameId];
} }
function loadGames() { function loadGamesFromDb(db) {
const gameRows = db.getMany('games');
for (const gameRow of gameRows) {
loadGameFromDb(db, gameRow.id);
}
}
function loadGameFromDb(db, gameId) {
const gameRow = db.get('games', { id: gameId });
let game;
try {
game = JSON.parse(gameRow.data);
}
catch {
log$3.log(`[ERR] unable to load game from db ${gameId}`);
}
if (typeof game.puzzle.data.started === 'undefined') {
game.puzzle.data.started = gameRow.created;
}
if (typeof game.puzzle.data.finished === 'undefined') {
game.puzzle.data.finished = gameRow.finished;
}
if (!Array.isArray(game.players)) {
game.players = Object.values(game.players);
}
const gameObject = storeDataToGame(game, game.creator_user_id);
GameCommon.setGame(gameObject.id, gameObject);
}
function persistGamesToDb(db) {
for (const gameId of Object.keys(dirtyGames)) {
persistGameToDb(db, gameId);
}
}
function persistGameToDb(db, gameId) {
const game = GameCommon.get(gameId);
if (!game) {
log$3.error(`[ERROR] unable to persist non existing game ${gameId}`);
return;
}
if (game.id in dirtyGames) {
setClean(game.id);
}
db.upsert('games', {
id: game.id,
creator_user_id: game.creatorUserId,
image_id: game.puzzle.info.image?.id,
created: game.puzzle.data.started,
finished: game.puzzle.data.finished,
data: gameToStoreData(game)
}, {
id: game.id,
});
log$3.info(`[INFO] persisted game ${game.id}`);
}
/**
* @deprecated
*/
function loadGamesFromDisk() {
const files = fs.readdirSync(DATA_DIR); const files = fs.readdirSync(DATA_DIR);
for (const f of files) { for (const f of files) {
const m = f.match(/^([a-z0-9]+)\.json$/); const m = f.match(/^([a-z0-9]+)\.json$/);
@ -1749,10 +1820,13 @@ function loadGames() {
continue; continue;
} }
const gameId = m[1]; const gameId = m[1];
loadGame(gameId); loadGameFromDisk(gameId);
} }
} }
function loadGame(gameId) { /**
* @deprecated
*/
function loadGameFromDisk(gameId) {
const file = `${DATA_DIR}/${gameId}.json`; const file = `${DATA_DIR}/${gameId}.json`;
const contents = fs.readFileSync(file, 'utf-8'); const contents = fs.readFileSync(file, 'utf-8');
let game; let game;
@ -1774,27 +1848,21 @@ function loadGame(gameId) {
if (!Array.isArray(game.players)) { if (!Array.isArray(game.players)) {
game.players = Object.values(game.players); game.players = Object.values(game.players);
} }
const gameObject = { const gameObject = storeDataToGame(game, null);
id: game.id,
rng: {
type: game.rng ? game.rng.type : '_fake_',
obj: game.rng ? Rng.unserialize(game.rng.obj) : new Rng(0),
},
puzzle: game.puzzle,
players: game.players,
evtInfos: {},
scoreMode: DefaultScoreMode(game.scoreMode),
shapeMode: DefaultShapeMode(game.shapeMode),
snapMode: DefaultSnapMode(game.snapMode),
};
GameCommon.setGame(gameObject.id, gameObject); GameCommon.setGame(gameObject.id, gameObject);
} }
function persistGames() { /**
* @deprecated
*/
function persistGamesToDisk() {
for (const gameId of Object.keys(dirtyGames)) { for (const gameId of Object.keys(dirtyGames)) {
persistGame(gameId); persistGameToDisk(gameId);
} }
} }
function persistGame(gameId) { /**
* @deprecated
*/
function persistGameToDisk(gameId) {
const game = GameCommon.get(gameId); const game = GameCommon.get(gameId);
if (!game) { if (!game) {
log$3.error(`[ERROR] unable to persist non existing game ${gameId}`); log$3.error(`[ERROR] unable to persist non existing game ${gameId}`);
@ -1803,7 +1871,27 @@ function persistGame(gameId) {
if (game.id in dirtyGames) { if (game.id in dirtyGames) {
setClean(game.id); setClean(game.id);
} }
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({ fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, gameToStoreData(game));
log$3.info(`[INFO] persisted game ${game.id}`);
}
function storeDataToGame(storeData, creatorUserId) {
return {
id: storeData.id,
creatorUserId,
rng: {
type: storeData.rng ? storeData.rng.type : '_fake_',
obj: storeData.rng ? Rng.unserialize(storeData.rng.obj) : new Rng(0),
},
puzzle: storeData.puzzle,
players: storeData.players,
evtInfos: {},
scoreMode: DefaultScoreMode(storeData.scoreMode),
shapeMode: DefaultShapeMode(storeData.shapeMode),
snapMode: DefaultSnapMode(storeData.snapMode),
};
}
function gameToStoreData(game) {
return JSON.stringify({
id: game.id, id: game.id,
rng: { rng: {
type: game.rng.type, type: game.rng.type,
@ -1814,22 +1902,27 @@ function persistGame(gameId) {
scoreMode: game.scoreMode, scoreMode: game.scoreMode,
shapeMode: game.shapeMode, shapeMode: game.shapeMode,
snapMode: game.snapMode, snapMode: game.snapMode,
})); });
log$3.info(`[INFO] persisted game ${game.id}`);
} }
var GameStorage = { var GameStorage = {
loadGames, // disk functions are deprecated
loadGame, loadGamesFromDisk,
persistGames, loadGameFromDisk,
persistGame, persistGamesToDisk,
persistGameToDisk,
loadGamesFromDb,
loadGameFromDb,
persistGamesToDb,
persistGameToDb,
setDirty, setDirty,
}; };
async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode) { async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId) {
const seed = Util.hash(gameId + ' ' + ts); const seed = Util.hash(gameId + ' ' + ts);
const rng = new Rng(seed); const rng = new Rng(seed);
return { return {
id: gameId, id: gameId,
creatorUserId,
rng: { type: 'Rng', obj: rng }, rng: { type: 'Rng', obj: rng },
puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode), puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode),
players: [], players: [],
@ -1839,10 +1932,10 @@ async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shape
snapMode, snapMode,
}; };
} }
async function createGame(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode) { async function createGame(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId) {
const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode); const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId);
GameLog.create(gameId, ts); GameLog.create(gameId, ts);
GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode, shapeMode, snapMode); GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode, shapeMode, snapMode, gameObject.creatorUserId);
GameCommon.setGame(gameObject.id, gameObject); GameCommon.setGame(gameObject.id, gameObject);
GameStorage.setDirty(gameId); GameStorage.setDirty(gameId);
} }
@ -2088,6 +2181,13 @@ const storage = multer.diskStorage({
} }
}); });
const upload = multer({ storage }).single('file'); const upload = multer({ storage }).single('file');
app.get('/api/me', (req, res) => {
let user = getUser(db, req);
res.send({
id: user ? user.id : null,
created: user ? user.created : null,
});
});
app.get('/api/conf', (req, res) => { app.get('/api/conf', (req, res) => {
res.send({ res.send({
WS_ADDRESS: config.ws.connectstring, WS_ADDRESS: config.ws.connectstring,
@ -2114,7 +2214,8 @@ app.get('/api/replay-data', async (req, res) => {
let game = null; let game = null;
if (offset === 0) { if (offset === 0) {
// also need the game // also need the game
game = await Game.createGameObject(gameId, log[0][2], log[0][3], log[0][4], log[0][5], log[0][6], log[0][7]); game = await Game.createGameObject(gameId, log[0][2], log[0][3], // must be ImageInfo
log[0][4], log[0][5], log[0][6], log[0][7], log[0][8]);
} }
res.send({ log, game: game ? Util.encodeGame(game) : null }); res.send({ log, game: game ? Util.encodeGame(game) : null });
}); });
@ -2145,6 +2246,28 @@ app.get('/api/index-data', (req, res) => {
gamesFinished: games.filter(g => !!g.finished), gamesFinished: games.filter(g => !!g.finished),
}); });
}); });
const getOrCreateUser = (db, req) => {
let user = getUser(db, req);
if (!user) {
db.insert('users', {
'client_id': req.headers['client-id'],
'client_secret': req.headers['client-secret'],
'created': Time.timestamp(),
});
user = getUser(db, req);
}
return user;
};
const getUser = (db, req) => {
let user = db.get('users', {
'client_id': req.headers['client-id'],
'client_secret': req.headers['client-secret'],
});
if (user) {
user.id = parseInt(user.id, 10);
}
return user;
};
const setImageTags = (db, imageId, tags) => { const setImageTags = (db, imageId, tags) => {
tags.forEach((tag) => { tags.forEach((tag) => {
const slug = Util.slug(tag); const slug = Util.slug(tag);
@ -2158,7 +2281,17 @@ const setImageTags = (db, imageId, tags) => {
}); });
}; };
app.post('/api/save-image', express.json(), (req, res) => { app.post('/api/save-image', express.json(), (req, res) => {
let user = getUser(db, req);
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' });
return;
}
const data = req.body; const data = req.body;
let image = db.get('images', { id: data.id });
if (parseInt(image.uploader_user_id, 10) !== user.id) {
res.status(403).send({ ok: false, error: 'forbidden' });
return;
}
db.update('images', { db.update('images', {
title: data.title, title: data.title,
}, { }, {
@ -2183,8 +2316,10 @@ app.post('/api/upload', (req, res) => {
log.log(err); log.log(err);
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!");
} }
const user = getOrCreateUser(db, req);
const dim = await Images.getDimensions(`${UPLOAD_DIR}/${req.file.filename}`); const dim = await Images.getDimensions(`${UPLOAD_DIR}/${req.file.filename}`);
const imageId = db.insert('images', { const imageId = db.insert('images', {
uploader_user_id: user.id,
filename: req.file.filename, filename: req.file.filename,
filename_original: req.file.originalname, filename_original: req.file.originalname,
title: req.body.title || '', title: req.body.title || '',
@ -2200,12 +2335,17 @@ app.post('/api/upload', (req, res) => {
}); });
}); });
app.post('/api/newgame', express.json(), async (req, res) => { app.post('/api/newgame', express.json(), async (req, res) => {
let user = getOrCreateUser(db, req);
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' });
return;
}
const gameSettings = req.body; const gameSettings = req.body;
log.log(gameSettings); log.log(gameSettings);
const gameId = Util.uniqId(); const gameId = Util.uniqId();
if (!GameCommon.exists(gameId)) { if (!GameCommon.exists(gameId)) {
const ts = Time.timestamp(); const ts = Time.timestamp();
await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode, gameSettings.shapeMode, gameSettings.snapMode); await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode, gameSettings.shapeMode, gameSettings.snapMode, user.id);
} }
res.send({ id: gameId }); res.send({ id: gameId });
}); });
@ -2287,7 +2427,7 @@ wss.on('message', async ({ socket, data }) => {
log.error(e); log.error(e);
} }
}); });
GameStorage.loadGames(); GameStorage.loadGamesFromDb(db);
const server = app.listen(port, hostname, () => log.log(`server running on http://${hostname}:${port}`)); const server = app.listen(port, hostname, () => log.log(`server running on http://${hostname}:${port}`));
wss.listen(); wss.listen();
const memoryUsageHuman = () => { const memoryUsageHuman = () => {
@ -2301,7 +2441,7 @@ memoryUsageHuman();
// persist games in fixed interval // persist games in fixed interval
const persistInterval = setInterval(() => { const persistInterval = setInterval(() => {
log.log('Persisting games...'); log.log('Persisting games...');
GameStorage.persistGames(); GameStorage.persistGamesToDb(db);
memoryUsageHuman(); memoryUsageHuman();
}, config.persistence.interval); }, config.persistence.interval);
const gracefulShutdown = (signal) => { const gracefulShutdown = (signal) => {
@ -2309,7 +2449,7 @@ const gracefulShutdown = (signal) => {
log.log('clearing persist interval...'); log.log('clearing persist interval...');
clearInterval(persistInterval); clearInterval(persistInterval);
log.log('persisting games...'); log.log('persisting games...');
GameStorage.persistGames(); GameStorage.persistGamesToDb(db);
log.log('shutting down webserver...'); log.log('shutting down webserver...');
server.close(); server.close();
log.log('shutting down websocketserver...'); log.log('shutting down websocketserver...');

View file

@ -0,0 +1,90 @@
import GameCommon from '../src/common/GameCommon'
import GameLog from '../src/server/GameLog'
import { Game } from '../src/common/Types'
import { logger } from '../src/common/Util'
import { DB_FILE, DB_PATCHES_DIR, UPLOAD_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import GameStorage from '../src/server/GameStorage'
import fs from 'fs'
const log = logger('fix_games_image_info.ts')
import Images from '../src/server/Images'
console.log(DB_FILE)
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
// ;(async () => {
// let images = db.getMany('images')
// for (let image of images) {
// console.log(image.filename)
// let dim = await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`)
// console.log(await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`))
// image.width = dim.w
// image.height = dim.h
// db.upsert('images', image, { id: image.id })
// }
// })()
function fixOne(gameId: string) {
let g = GameCommon.get(gameId)
if (!g) {
return
}
if (!g.puzzle.info.image && g.puzzle.info.imageUrl) {
log.log('game id: ', gameId)
const parts = g.puzzle.info.imageUrl.split('/')
const fileName = parts[parts.length - 1]
const imageRow = db.get('images', {filename: fileName})
if (!imageRow) {
return
}
g.puzzle.info.image = Images.imageFromDb(db, imageRow.id)
log.log(g.puzzle.info.image.title, imageRow.id)
GameStorage.persistGameToDb(db, gameId)
} else if (g.puzzle.info.image?.id) {
const imageId = g.puzzle.info.image.id
g.puzzle.info.image = Images.imageFromDb(db, imageId)
log.log(g.puzzle.info.image.title, imageId)
GameStorage.persistGameToDb(db, gameId)
}
// fix log
const file = GameLog.filename(gameId, 0)
if (!fs.existsSync(file)) {
return
}
const lines = fs.readFileSync(file, 'utf-8').split("\n")
const l = lines.filter(line => !!line).map(line => {
return JSON.parse(`[${line}]`)
})
if (l && l[0] && !l[0][3].id) {
log.log(l[0][3])
l[0][3] = g.puzzle.info.image
const newlines = l.map(ll => {
return JSON.stringify(ll).slice(1, -1)
}).join("\n") + "\n"
console.log(g.puzzle.info.image)
// process.exit(0)
fs.writeFileSync(file, newlines)
}
}
function fix() {
GameStorage.loadGamesFromDisk()
GameCommon.getAllGames().forEach((game: Game) => {
fixOne(game.id)
})
}
fix()

View file

@ -1,23 +0,0 @@
import GameCommon from '../src/common/GameCommon'
import { logger } from '../src/common/Util'
import GameStorage from '../src/server/GameStorage'
const log = logger('fix_image.js')
function fix(gameId) {
GameStorage.loadGame(gameId)
let changed = false
let imgUrl = GameCommon.getImageUrl(gameId)
if (imgUrl.match(/^\/example-images\//)) {
log.log(`found bad imgUrl: ${imgUrl}`)
imgUrl = imgUrl.replace(/^\/example-images\//, '/uploads/')
GameCommon.setImageUrl(gameId, imgUrl)
changed = true
}
if (changed) {
GameStorage.persistGame(gameId)
}
}
fix(process.argv[2])

View file

@ -1,11 +1,16 @@
import GameCommon from '../src/common/GameCommon' import GameCommon from '../src/common/GameCommon'
import { logger } from '../src/common/Util' import { logger } from '../src/common/Util'
import Db from '../src/server/Db'
import { DB_FILE, DB_PATCHES_DIR } from '../src/server/Dirs'
import GameStorage from '../src/server/GameStorage' import GameStorage from '../src/server/GameStorage'
const log = logger('fix_tiles.js') const log = logger('fix_tiles.js')
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
function fix_tiles(gameId) { function fix_tiles(gameId) {
GameStorage.loadGame(gameId) GameStorage.loadGameFromDb(db, gameId)
let changed = false let changed = false
const tiles = GameCommon.getPiecesSortedByZIndex(gameId) const tiles = GameCommon.getPiecesSortedByZIndex(gameId)
for (let tile of tiles) { for (let tile of tiles) {
@ -27,7 +32,7 @@ function fix_tiles(gameId) {
} }
} }
if (changed) { if (changed) {
GameStorage.persistGame(gameId) GameStorage.persistGameToDb(db, gameId)
} }
} }

27
scripts/import_games.ts Normal file
View file

@ -0,0 +1,27 @@
import GameCommon from '../src/common/GameCommon'
import { Game } from '../src/common/Types'
import { logger } from '../src/common/Util'
import { DB_FILE, DB_PATCHES_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import GameStorage from '../src/server/GameStorage'
const log = logger('import_games.ts')
console.log(DB_FILE)
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
function run() {
GameStorage.loadGamesFromDisk()
GameCommon.getAllGames().forEach((game: Game) => {
if (!game.puzzle.info.image?.id) {
log.error(game.id + " has no image")
log.error(game.puzzle.info.image)
return
}
GameStorage.persistGameToDb(db, game.id)
})
}
run()

View file

@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
# server for built files # server for built files
nodemon --max-old-space-size=64 -e js build/server/main.js -c config.json nodemon --watch build --max-old-space-size=64 -e js build/server/main.js -c config.json

View file

@ -138,12 +138,16 @@ function setEvtInfo(
function getAllGames(): Array<Game> { function getAllGames(): Array<Game> {
return Object.values(GAMES).sort((a: Game, b: Game) => { return Object.values(GAMES).sort((a: Game, b: Game) => {
const finished = isFinished(a.id)
// when both have same finished state, sort by started // when both have same finished state, sort by started
if (isFinished(a.id) === isFinished(b.id)) { if (finished === isFinished(b.id)) {
if (finished) {
return b.puzzle.data.finished - a.puzzle.data.finished
}
return b.puzzle.data.started - a.puzzle.data.started return b.puzzle.data.started - a.puzzle.data.started
} }
// otherwise, sort: unfinished, finished // otherwise, sort: unfinished, finished
return isFinished(a.id) ? 1 : -1 return finished ? 1 : -1
}) })
} }
@ -162,11 +166,12 @@ function getPieceCount(gameId: string): number {
} }
function getImageUrl(gameId: string): string { function getImageUrl(gameId: string): string {
return GAMES[gameId].puzzle.info.imageUrl const imageUrl = GAMES[gameId].puzzle.info.image?.url
} || GAMES[gameId].puzzle.info.imageUrl
if (!imageUrl) {
function setImageUrl(gameId: string, imageUrl: string): void { throw new Error('[2021-07-11] no image url set')
GAMES[gameId].puzzle.info.imageUrl = imageUrl }
return imageUrl
} }
function getScoreMode(gameId: string): ScoreMode { function getScoreMode(gameId: string): ScoreMode {
@ -895,7 +900,6 @@ export default {
getFinishedPiecesCount, getFinishedPiecesCount,
getPieceCount, getPieceCount,
getImageUrl, getImageUrl,
setImageUrl,
get, get,
getAllGames, getAllGames,
getPlayerBgColor, getPlayerBgColor,

View file

@ -67,6 +67,9 @@ const INPUT_EV_REPLAY_SPEED_DOWN = 14
const INPUT_EV_TOGGLE_PLAYER_NAMES = 15 const INPUT_EV_TOGGLE_PLAYER_NAMES = 15
const INPUT_EV_CENTER_FIT_PUZZLE = 16 const INPUT_EV_CENTER_FIT_PUZZLE = 16
const INPUT_EV_TOGGLE_FIXED_PIECES = 17
const INPUT_EV_TOGGLE_LOOSE_PIECES = 18
const CHANGE_DATA = 1 const CHANGE_DATA = 1
const CHANGE_TILE = 2 const CHANGE_TILE = 2
const CHANGE_PLAYER = 3 const CHANGE_PLAYER = 3
@ -104,6 +107,9 @@ export default {
INPUT_EV_TOGGLE_PLAYER_NAMES, INPUT_EV_TOGGLE_PLAYER_NAMES,
INPUT_EV_CENTER_FIT_PUZZLE, INPUT_EV_CENTER_FIT_PUZZLE,
INPUT_EV_TOGGLE_FIXED_PIECES,
INPUT_EV_TOGGLE_LOOSE_PIECES,
CHANGE_DATA, CHANGE_DATA,
CHANGE_TILE, CHANGE_TILE,
CHANGE_PLAYER, CHANGE_PLAYER,

View file

@ -51,6 +51,7 @@ export type EncodedGame = FixedLengthArray<[
ScoreMode, ScoreMode,
ShapeMode, ShapeMode,
SnapMode, SnapMode,
number|null,
]> ]>
export interface ReplayData { export interface ReplayData {
@ -72,6 +73,7 @@ interface GameRng {
export interface Game { export interface Game {
id: string id: string
creatorUserId: number|null
players: Array<EncodedPlayer> players: Array<EncodedPlayer>
puzzle: Puzzle puzzle: Puzzle
evtInfos: Record<string, EvtInfo> evtInfos: Record<string, EvtInfo>
@ -93,7 +95,7 @@ export interface Image {
export interface GameSettings { export interface GameSettings {
tiles: number tiles: number
image: Image image: ImageInfo
scoreMode: ScoreMode scoreMode: ScoreMode
shapeMode: ShapeMode shapeMode: ShapeMode
snapMode: SnapMode snapMode: SnapMode
@ -152,10 +154,24 @@ export interface PieceChange {
group?: number group?: number
} }
export interface ImageInfo
{
id: number
uploaderUserId: number|null
filename: string
url: string
title: string
tags: Tag[]
created: Timestamp
width: number
height: number
}
export interface PuzzleInfo { export interface PuzzleInfo {
table: PuzzleTable table: PuzzleTable
targetTiles: number, targetTiles: number
imageUrl: string imageUrl?: string // deprecated, use image.url instead
image?: ImageInfo
width: number width: number
height: number height: number

View file

@ -133,6 +133,7 @@ function encodeGame(data: Game): EncodedGame {
data.scoreMode, data.scoreMode,
data.shapeMode, data.shapeMode,
data.snapMode, data.snapMode,
data.creatorUserId,
] ]
} }
@ -149,6 +150,7 @@ function decodeGame(data: EncodedGame): Game {
scoreMode: data[6], scoreMode: data[6],
shapeMode: data[7], shapeMode: data[7],
snapMode: data[8], snapMode: data[8],
creatorUserId: data[9],
} }
} }

View file

@ -0,0 +1,45 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TIMESTAMP NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL
);
CREATE TABLE images_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uploader_user_id INTEGER,
created TIMESTAMP NOT NULL,
filename TEXT NOT NULL UNIQUE,
filename_original TEXT NOT NULL,
title TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE image_x_category_new (
image_id INTEGER NOT NULL,
category_id INTEGER NOT NULL
);
INSERT INTO images_new
SELECT id, NULL, created, filename, filename_original, title, width, height
FROM images;
INSERT INTO image_x_category_new
SELECT image_id, category_id
FROM image_x_category;
PRAGMA foreign_keys = OFF;
DROP TABLE images;
DROP TABLE image_x_category;
ALTER TABLE images_new RENAME TO images;
ALTER TABLE image_x_category_new RENAME TO image_x_category;
PRAGMA foreign_keys = ON;

View file

@ -0,0 +1,11 @@
CREATE TABLE games (
id TEXT PRIMARY KEY,
creator_user_id INTEGER,
image_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL,
finished TIMESTAMP NOT NULL,
data TEXT NOT NULL
);

View file

@ -3,6 +3,7 @@
import { ClientEvent, EncodedGame, GameEvent, ReplayData, ServerEvent } from '../common/Types' import { ClientEvent, EncodedGame, GameEvent, ReplayData, ServerEvent } from '../common/Types'
import Util, { logger } from '../common/Util' import Util, { logger } from '../common/Util'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import xhr from './xhr'
const log = logger('Communication.js') const log = logger('Communication.js')
@ -117,7 +118,7 @@ async function requestReplayData(
offset: number offset: number
): Promise<ReplayData> { ): Promise<ReplayData> {
const args = { gameId, offset } const args = { gameId, offset }
const res = await fetch(`/api/replay-data${Util.asQueryArgs(args)}`) const res = await xhr.get(`/api/replay-data${Util.asQueryArgs(args)}`, {})
const json: ReplayData = await res.json() const json: ReplayData = await res.json()
return json return json
} }

View file

@ -0,0 +1,177 @@
import Protocol from "../common/Protocol"
import { GameEvent } from "../common/Types"
import { MODE_REPLAY } from "./game"
function EventAdapter (
canvas: HTMLCanvasElement,
window: any,
viewport: any,
MODE: string
) {
let events: Array<GameEvent> = []
let KEYS_ON = true
let LEFT = false
let RIGHT = false
let UP = false
let DOWN = false
let ZOOM_IN = false
let ZOOM_OUT = false
let SHIFT = false
const toWorldPoint = (x: number, y: number): [number, number] => {
const pos = viewport.viewportToWorld({x, y})
return [pos.x, pos.y]
}
const mousePos = (ev: MouseEvent) => toWorldPoint(ev.offsetX, ev.offsetY)
const canvasCenter = () => toWorldPoint(canvas.width / 2, canvas.height / 2)
const key = (state: boolean, ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'ShiftLeft' || ev.code === 'ShiftRight') {
SHIFT = state
} else if (ev.code === 'ArrowUp' || ev.code === 'KeyW') {
UP = state
} else if (ev.code === 'ArrowDown' || ev.code === 'KeyS') {
DOWN = state
} else if (ev.code === 'ArrowLeft' || ev.code === 'KeyA') {
LEFT = state
} else if (ev.code === 'ArrowRight' || ev.code === 'KeyD') {
RIGHT = state
} else if (ev.code === 'KeyQ') {
ZOOM_OUT = state
} else if (ev.code === 'KeyE') {
ZOOM_IN = state
}
}
let lastMouse: [number, number]|null = null
canvas.addEventListener('mousedown', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_DOWN, ...lastMouse])
}
})
canvas.addEventListener('mouseup', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_UP, ...lastMouse])
}
})
canvas.addEventListener('mousemove', (ev) => {
lastMouse = mousePos(ev)
addEvent([Protocol.INPUT_EV_MOUSE_MOVE, ...lastMouse])
})
canvas.addEventListener('wheel', (ev) => {
lastMouse = mousePos(ev)
if (viewport.canZoom(ev.deltaY < 0 ? 'in' : 'out')) {
const evt = ev.deltaY < 0
? Protocol.INPUT_EV_ZOOM_IN
: Protocol.INPUT_EV_ZOOM_OUT
addEvent([evt, ...lastMouse])
}
})
window.addEventListener('keydown', (ev: KeyboardEvent) => key(true, ev))
window.addEventListener('keyup', (ev: KeyboardEvent) => key(false, ev))
window.addEventListener('keypress', (ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'Space') {
addEvent([Protocol.INPUT_EV_TOGGLE_PREVIEW])
}
if (MODE === MODE_REPLAY) {
if (ev.code === 'KeyI') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_UP])
}
if (ev.code === 'KeyO') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_DOWN])
}
if (ev.code === 'KeyP') {
addEvent([Protocol.INPUT_EV_REPLAY_TOGGLE_PAUSE])
}
}
if (ev.code === 'KeyF') {
addEvent([Protocol.INPUT_EV_TOGGLE_FIXED_PIECES])
}
if (ev.code === 'KeyG') {
addEvent([Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES])
}
if (ev.code === 'KeyM') {
addEvent([Protocol.INPUT_EV_TOGGLE_SOUNDS])
}
if (ev.code === 'KeyN') {
addEvent([Protocol.INPUT_EV_TOGGLE_PLAYER_NAMES])
}
if (ev.code === 'KeyC') {
addEvent([Protocol.INPUT_EV_CENTER_FIT_PUZZLE])
}
})
const addEvent = (event: GameEvent) => {
events.push(event)
}
const consumeAll = (): GameEvent[] => {
if (events.length === 0) {
return []
}
const all = events.slice()
events = []
return all
}
const createKeyEvents = (): void => {
const w = (LEFT ? 1 : 0) - (RIGHT ? 1 : 0)
const h = (UP ? 1 : 0) - (DOWN ? 1 : 0)
if (w !== 0 || h !== 0) {
const amount = (SHIFT ? 24 : 12) * Math.sqrt(viewport.getCurrentZoom())
const pos = viewport.viewportDimToWorld({w: w * amount, h: h * amount})
addEvent([Protocol.INPUT_EV_MOVE, pos.w, pos.h])
if (lastMouse) {
lastMouse[0] -= pos.w
lastMouse[1] -= pos.h
}
}
if (ZOOM_IN && ZOOM_OUT) {
// cancel each other out
} else if (ZOOM_IN) {
if (viewport.canZoom('in')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_IN, ...target])
}
} else if (ZOOM_OUT) {
if (viewport.canZoom('out')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_OUT, ...target])
}
}
}
const setHotkeys = (state: boolean) => {
KEYS_ON = state
}
return {
addEvent,
consumeAll,
createKeyEvents,
setHotkeys,
}
}
export default EventAdapter

View file

@ -3,7 +3,7 @@
import Geometry, { Rect } from '../common/Geometry' import Geometry, { Rect } from '../common/Geometry'
import Graphics from './Graphics' import Graphics from './Graphics'
import Util, { logger } from './../common/Util' import Util, { logger } from './../common/Util'
import { Puzzle, PuzzleInfo, PieceShape, EncodedPiece } from './../common/GameCommon' import { Puzzle, PuzzleInfo, PieceShape, EncodedPiece } from './../common/Types'
const log = logger('PuzzleGraphics.js') const log = logger('PuzzleGraphics.js')

View file

@ -3,7 +3,7 @@
class="imageteaser" class="imageteaser"
:style="style" :style="style"
@click="onClick"> @click="onClick">
<div class="btn edit" @click.stop="onEditClick"></div> <div class="btn edit" v-if="canEdit" @click.stop="onEditClick"></div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -18,12 +18,18 @@ export default defineComponent({
}, },
}, },
computed: { computed: {
style (): object { style(): object {
const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp' const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp'
return { return {
'backgroundImage': `url("${url}")`, 'backgroundImage': `url("${url}")`,
} }
}, },
canEdit(): boolean {
if (!this.$me.id) {
return false
}
return this.$me.id === this.image.uploaderUserId
},
}, },
emits: { emits: {
click: null, click: null,

View file

@ -0,0 +1,68 @@
<template>
<div class="overlay transparent" @click="$emit('bgclick')">
<table class="overlay-content help" @click.stop="">
<tr>
<td colspan="2">Info about this puzzle</td>
</tr>
<tr>
<td>Image Title: </td>
<td>{{game.puzzle.info.image.title}}</td>
</tr>
<tr>
<td>Scoring: </td>
<td><span :title="snapMode[1]">{{scoreMode[0]}}</span></td>
</tr>
<tr>
<td>Shapes: </td>
<td><span :title="snapMode[1]">{{shapeMode[0]}}</span></td>
</tr>
<tr>
<td>Snapping: </td>
<td><span :title="snapMode[1]">{{snapMode[0]}}</span></td>
</tr>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { Game, ScoreMode, ShapeMode, SnapMode } from '../../common/Types'
export default defineComponent({
name: 'help-overlay',
emits: {
bgclick: null,
},
props: {
game: {
type: Object as PropType<Game>,
required: true,
},
},
computed: {
scoreMode () {
switch (this.game.scoreMode) {
case ScoreMode.ANY: return ['Any', 'Score when pieces are connected to each other or on final location']
case ScoreMode.FINAL:
default: return ['Final', 'Score when pieces are put to their final location']
}
},
shapeMode () {
switch (this.game.shapeMode) {
case ShapeMode.FLAT: return ['Flat', 'All pieces flat on all sides']
case ShapeMode.ANY: return ['Any', 'Flat pieces can occur anywhere']
case ShapeMode.NORMAL:
default:
return ['Normal', '']
}
},
snapMode () {
switch (this.game.snapMode) {
case SnapMode.REAL: return ['Real', 'Pieces snap only to corners, already snapped pieces and to each other']
case SnapMode.NORMAL:
default:
return ['Normal', 'Pieces snap to final destination and to each other']
}
},
},
})
</script>

View file

@ -35,20 +35,20 @@
Normal</label> Normal</label>
<br /> <br />
<label><input type="radio" v-model="shapeMode" value="1" /> <label><input type="radio" v-model="shapeMode" value="1" />
Any (flat pieces can occur anywhere)</label> Any (Flat pieces can occur anywhere)</label>
<br /> <br />
<label><input type="radio" v-model="shapeMode" value="2" /> <label><input type="radio" v-model="shapeMode" value="2" />
Flat (all pieces flat on all sides)</label> Flat (All pieces flat on all sides)</label>
</td> </td>
</tr> </tr>
<tr> <tr>
<td><label>Snapping: </label></td> <td><label>Snapping: </label></td>
<td> <td>
<label><input type="radio" v-model="snapMode" value="0" /> <label><input type="radio" v-model="snapMode" value="0" />
Normal (pieces snap to final destination and to each other)</label> Normal (Pieces snap to final destination and to each other)</label>
<br /> <br />
<label><input type="radio" v-model="snapMode" value="1" /> <label><input type="radio" v-model="snapMode" value="1" />
Real (pieces snap only to corners, already snapped pieces and to each other)</label> Real (Pieces snap only to corners, already snapped pieces and to each other)</label>
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -6,6 +6,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import xhr from '../xhr'
export default defineComponent({ export default defineComponent({
name: 'upload', name: 'upload',
@ -21,8 +22,7 @@ export default defineComponent({
if (!file) return; if (!file) return;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch('/upload', { const res = await xhr.post('/upload', {
method: 'post',
body: formData, body: formData,
}) })
const j = await res.json() const j = await res.json()

View file

@ -22,9 +22,9 @@ import {
EncodedGame, EncodedGame,
ReplayData, ReplayData,
Timestamp, Timestamp,
GameEvent,
ServerEvent, ServerEvent,
} from '../common/Types' } from '../common/Types'
import EventAdapter from './EventAdapter'
declare global { declare global {
interface Window { interface Window {
DEBUG?: boolean DEBUG?: boolean
@ -96,180 +96,6 @@ function addCanvasToDom(TARGET_EL: HTMLElement, canvas: HTMLCanvasElement) {
return canvas return canvas
} }
function EventAdapter (
canvas: HTMLCanvasElement,
window: any,
viewport: any,
MODE: string
) {
let events: Array<GameEvent> = []
let KEYS_ON = true
let LEFT = false
let RIGHT = false
let UP = false
let DOWN = false
let ZOOM_IN = false
let ZOOM_OUT = false
let SHIFT = false
const toWorldPoint = (x: number, y: number): [number, number] => {
const pos = viewport.viewportToWorld({x, y})
return [pos.x, pos.y]
}
const mousePos = (ev: MouseEvent) => toWorldPoint(ev.offsetX, ev.offsetY)
const canvasCenter = () => toWorldPoint(canvas.width / 2, canvas.height / 2)
const key = (state: boolean, ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'ShiftLeft' || ev.code === 'ShiftRight') {
SHIFT = state
} else if (ev.code === 'ArrowUp' || ev.code === 'KeyW') {
UP = state
} else if (ev.code === 'ArrowDown' || ev.code === 'KeyS') {
DOWN = state
} else if (ev.code === 'ArrowLeft' || ev.code === 'KeyA') {
LEFT = state
} else if (ev.code === 'ArrowRight' || ev.code === 'KeyD') {
RIGHT = state
} else if (ev.code === 'KeyQ') {
ZOOM_OUT = state
} else if (ev.code === 'KeyE') {
ZOOM_IN = state
}
}
let lastMouse: [number, number]|null = null
canvas.addEventListener('mousedown', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_DOWN, ...lastMouse])
}
})
canvas.addEventListener('mouseup', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_UP, ...lastMouse])
}
})
canvas.addEventListener('mousemove', (ev) => {
lastMouse = mousePos(ev)
addEvent([Protocol.INPUT_EV_MOUSE_MOVE, ...lastMouse])
})
canvas.addEventListener('wheel', (ev) => {
lastMouse = mousePos(ev)
if (viewport.canZoom(ev.deltaY < 0 ? 'in' : 'out')) {
const evt = ev.deltaY < 0
? Protocol.INPUT_EV_ZOOM_IN
: Protocol.INPUT_EV_ZOOM_OUT
addEvent([evt, ...lastMouse])
}
})
window.addEventListener('keydown', (ev: KeyboardEvent) => key(true, ev))
window.addEventListener('keyup', (ev: KeyboardEvent) => key(false, ev))
window.addEventListener('keypress', (ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'Space') {
addEvent([Protocol.INPUT_EV_TOGGLE_PREVIEW])
}
if (MODE === MODE_REPLAY) {
if (ev.code === 'KeyI') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_UP])
}
if (ev.code === 'KeyO') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_DOWN])
}
if (ev.code === 'KeyP') {
addEvent([Protocol.INPUT_EV_REPLAY_TOGGLE_PAUSE])
}
}
if (ev.code === 'KeyF') {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
}
if (ev.code === 'KeyG') {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
}
if (ev.code === 'KeyM') {
addEvent([Protocol.INPUT_EV_TOGGLE_SOUNDS])
}
if (ev.code === 'KeyN') {
addEvent([Protocol.INPUT_EV_TOGGLE_PLAYER_NAMES])
}
if (ev.code === 'KeyC') {
addEvent([Protocol.INPUT_EV_CENTER_FIT_PUZZLE])
}
})
const addEvent = (event: GameEvent) => {
events.push(event)
}
const consumeAll = (): GameEvent[] => {
if (events.length === 0) {
return []
}
const all = events.slice()
events = []
return all
}
const createKeyEvents = (): void => {
const w = (LEFT ? 1 : 0) - (RIGHT ? 1 : 0)
const h = (UP ? 1 : 0) - (DOWN ? 1 : 0)
if (w !== 0 || h !== 0) {
const amount = (SHIFT ? 24 : 12) * Math.sqrt(viewport.getCurrentZoom())
const pos = viewport.viewportDimToWorld({w: w * amount, h: h * amount})
addEvent([Protocol.INPUT_EV_MOVE, pos.w, pos.h])
if (lastMouse) {
lastMouse[0] -= pos.w
lastMouse[1] -= pos.h
}
}
if (ZOOM_IN && ZOOM_OUT) {
// cancel each other out
} else if (ZOOM_IN) {
if (viewport.canZoom('in')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_IN, ...target])
}
} else if (ZOOM_OUT) {
if (viewport.canZoom('out')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_OUT, ...target])
}
}
}
const setHotkeys = (state: boolean) => {
KEYS_ON = state
}
return {
addEvent,
consumeAll,
createKeyEvents,
setHotkeys,
}
}
export async function main( export async function main(
gameId: string, gameId: string,
clientId: string, clientId: string,
@ -746,6 +572,12 @@ export async function main(
HUD.togglePlayerNames() HUD.togglePlayerNames()
} else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) { } else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) {
centerPuzzle() centerPuzzle()
} else if (type === Protocol.INPUT_EV_TOGGLE_FIXED_PIECES) {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
} else if (type === Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES) {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
} }
// LOCAL + SERVER CHANGES // LOCAL + SERVER CHANGES
@ -818,6 +650,12 @@ export async function main(
HUD.togglePlayerNames() HUD.togglePlayerNames()
} else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) { } else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) {
centerPuzzle() centerPuzzle()
} else if (type === Protocol.INPUT_EV_TOGGLE_FIXED_PIECES) {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
} else if (type === Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES) {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
} }
} }
} }
@ -969,6 +807,7 @@ export async function main(
soundsVolume: playerSoundVolume(), soundsVolume: playerSoundVolume(),
showPlayerNames: showPlayerNames(), showPlayerNames: showPlayerNames(),
}, },
game: Game.get(gameId),
disconnect: Communication.disconnect, disconnect: Communication.disconnect,
connect: connect, connect: connect,
unload: unload, unload: unload,

View file

@ -7,19 +7,36 @@ import NewGame from './views/NewGame.vue'
import Game from './views/Game.vue' import Game from './views/Game.vue'
import Replay from './views/Replay.vue' import Replay from './views/Replay.vue'
import Util from './../common/Util' import Util from './../common/Util'
import settings from './settings'
import xhr from './xhr'
(async () => { (async () => {
const res = await fetch(`/api/conf`) function initClientSecret() {
const conf = await res.json() let SECRET = settings.getStr('SECRET', '')
if (!SECRET) {
function initme() { SECRET = Util.uniqId()
let ID = localStorage.getItem('ID') settings.setStr('SECRET', SECRET)
}
return SECRET
}
function initClientId() {
let ID = settings.getStr('ID', '')
if (!ID) { if (!ID) {
ID = Util.uniqId() ID = Util.uniqId()
localStorage.setItem('ID', ID) settings.setStr('ID', ID)
} }
return ID return ID
} }
const clientId = initClientId()
const clientSecret = initClientSecret()
xhr.setClientId(clientId)
xhr.setClientSecret(clientSecret)
const meRes = await xhr.get(`/api/me`, {})
const me = await meRes.json()
const confRes = await xhr.get(`/api/conf`, {})
const conf = await confRes.json()
const router = VueRouter.createRouter({ const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(), history: VueRouter.createWebHashHistory(),
@ -39,8 +56,9 @@ import Util from './../common/Util'
}) })
const app = Vue.createApp(App) const app = Vue.createApp(App)
app.config.globalProperties.$me = me
app.config.globalProperties.$config = conf app.config.globalProperties.$config = conf
app.config.globalProperties.$clientId = initme() app.config.globalProperties.$clientId = clientId
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
})() })()

View file

@ -2,6 +2,7 @@
<div id="game"> <div id="game">
<settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" /> <settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" />
<preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" /> <preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" />
<info-overlay v-if="g.game" v-show="overlay === 'info'" @bgclick="toggle('info', true)" :game="g.game" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" /> <help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<div class="overlay" v-if="cuttingPuzzle"> <div class="overlay" v-if="cuttingPuzzle">
@ -27,7 +28,8 @@
<router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link> <router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link>
<div class="opener" @click="toggle('preview', false)">🖼 Preview</div> <div class="opener" @click="toggle('preview', false)">🖼 Preview</div>
<div class="opener" @click="toggle('settings', true)">🛠 Settings</div> <div class="opener" @click="toggle('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div> <div class="opener" @click="toggle('info', true)"> Info</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -41,11 +43,12 @@ import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue' import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue' import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue' import PreviewOverlay from './../components/PreviewOverlay.vue'
import InfoOverlay from './../components/InfoOverlay.vue'
import ConnectionOverlay from './../components/ConnectionOverlay.vue' import ConnectionOverlay from './../components/ConnectionOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue' import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_PLAY } from './../game' import { main, MODE_PLAY } from './../game'
import { Player } from '../../common/Types' import { Game, Player } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'game', name: 'game',
@ -54,6 +57,7 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
ConnectionOverlay, ConnectionOverlay,
HelpOverlay, HelpOverlay,
}, },
@ -81,6 +85,7 @@ export default defineComponent({
soundsVolume: 100, soundsVolume: 100,
showPlayerNames: true, showPlayerNames: true,
}, },
game: null as Game|null,
previewImageUrl: '', previewImageUrl: '',
setHotkeys: (v: boolean) => {}, setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {}, onBgChange: (v: string) => {},

View file

@ -13,6 +13,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import xhr from '../xhr'
import GameTeaser from './../components/GameTeaser.vue' import GameTeaser from './../components/GameTeaser.vue'
@ -27,7 +28,7 @@ export default defineComponent({
} }
}, },
async created() { async created() {
const res = await fetch('/api/index-data') const res = await xhr.get('/api/index-data', {})
const json = await res.json() const json = await res.json()
this.gamesRunning = json.gamesRunning this.gamesRunning = json.gamesRunning
this.gamesFinished = json.gamesFinished this.gamesFinished = json.gamesFinished

View file

@ -133,7 +133,7 @@ export default defineComponent({
this.filtersChanged() this.filtersChanged()
}, },
async loadImages () { async loadImages () {
const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`) const res = await xhr.get(`/api/newgame-data${Util.asQueryArgs(this.filters)}`, {})
const json = await res.json() const json = await res.json()
this.images = json.images this.images = json.images
this.tags = json.tags this.tags = json.tags
@ -165,8 +165,7 @@ export default defineComponent({
return await res.json() return await res.json()
}, },
async saveImage (data: any) { async saveImage (data: any) {
const res = await fetch('/api/save-image', { const res = await xhr.post('/api/save-image', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -180,9 +179,13 @@ export default defineComponent({
return await res.json() return await res.json()
}, },
async onSaveImageClick(data: any) { async onSaveImageClick(data: any) {
await this.saveImage(data) const res = await this.saveImage(data)
if (res.ok) {
this.dialog = '' this.dialog = ''
await this.loadImages() await this.loadImages()
} else {
alert(res.error)
}
}, },
async postToGalleryClick(data: any) { async postToGalleryClick(data: any) {
this.uploading = 'postToGallery' this.uploading = 'postToGallery'
@ -200,8 +203,7 @@ export default defineComponent({
this.dialog = 'new-game' this.dialog = 'new-game'
}, },
async onNewGame(gameSettings: GameSettings) { async onNewGame(gameSettings: GameSettings) {
const res = await fetch('/api/newgame', { const res = await xhr.post('/api/newgame', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'

View file

@ -2,6 +2,7 @@
<div id="replay"> <div id="replay">
<settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" /> <settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" />
<preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" /> <preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" />
<info-overlay v-if="g.game" v-show="overlay === 'info'" @bgclick="toggle('info', true)" :game="g.game" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" /> <help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<div class="overlay" v-if="cuttingPuzzle"> <div class="overlay" v-if="cuttingPuzzle">
@ -29,7 +30,8 @@
<router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link> <router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link>
<div class="opener" @click="toggle('preview', false)">🖼 Preview</div> <div class="opener" @click="toggle('preview', false)">🖼 Preview</div>
<div class="opener" @click="toggle('settings', true)">🛠 Settings</div> <div class="opener" @click="toggle('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div> <div class="opener" @click="toggle('info', true)"> Info</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -43,10 +45,11 @@ import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue' import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue' import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue' import PreviewOverlay from './../components/PreviewOverlay.vue'
import InfoOverlay from './../components/InfoOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue' import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_REPLAY } from './../game' import { main, MODE_REPLAY } from './../game'
import { Player } from '../../common/Types' import { Game, Player } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'replay', name: 'replay',
@ -55,6 +58,7 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
HelpOverlay, HelpOverlay,
}, },
data() { data() {
@ -81,6 +85,7 @@ export default defineComponent({
soundsVolume: 100, soundsVolume: 100,
showPlayerNames: true, showPlayerNames: true,
}, },
game: null as Game|null,
previewImageUrl: '', previewImageUrl: '',
setHotkeys: (v: boolean) => {}, setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {}, onBgChange: (v: string) => {},

View file

@ -10,6 +10,8 @@ export interface Options {
onUploadProgress?: (ev: ProgressEvent<XMLHttpRequestEventTarget>) => any, onUploadProgress?: (ev: ProgressEvent<XMLHttpRequestEventTarget>) => any,
} }
let xhrClientId: string = ''
let xhrClientSecret: string = ''
const request = async ( const request = async (
method: string, method: string,
url: string, url: string,
@ -22,6 +24,10 @@ const request = async (
for (const k in options.headers || {}) { for (const k in options.headers || {}) {
xhr.setRequestHeader(k, options.headers[k]) xhr.setRequestHeader(k, options.headers[k])
} }
xhr.setRequestHeader('Client-Id', xhrClientId)
xhr.setRequestHeader('Client-Secret', xhrClientSecret)
xhr.addEventListener('load', function (ev: ProgressEvent<XMLHttpRequestEventTarget> xhr.addEventListener('load', function (ev: ProgressEvent<XMLHttpRequestEventTarget>
) { ) {
resolve({ resolve({
@ -41,7 +47,7 @@ const request = async (
} }
}) })
} }
xhr.send(options.body) xhr.send(options.body || null)
}) })
} }
@ -53,4 +59,10 @@ export default {
post: (url: string, options: any): Promise<Response> => { post: (url: string, options: any): Promise<Response> => {
return request('post', url, options) return request('post', url, options)
}, },
setClientId: (clientId: string): void => {
xhrClientId = clientId
},
setClientSecret: (clientSecret: string): void => {
xhrClientSecret = clientSecret
},
} }

View file

@ -1,9 +1,9 @@
import GameCommon from './../common/GameCommon' import GameCommon from './../common/GameCommon'
import { Change, Game, Input, ScoreMode, ShapeMode, SnapMode, Timestamp } from './../common/Types' import { Change, Game, Input, ScoreMode, ShapeMode, SnapMode,ImageInfo, Timestamp, GameSettings } from './../common/Types'
import Util, { logger } from './../common/Util' import Util, { logger } from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import GameLog from './GameLog' import GameLog from './GameLog'
import { createPuzzle, PuzzleCreationImageInfo } from './Puzzle' import { createPuzzle } from './Puzzle'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import GameStorage from './GameStorage' import GameStorage from './GameStorage'
@ -12,16 +12,18 @@ const log = logger('Game.ts')
async function createGameObject( async function createGameObject(
gameId: string, gameId: string,
targetTiles: number, targetTiles: number,
image: PuzzleCreationImageInfo, image: ImageInfo,
ts: Timestamp, ts: Timestamp,
scoreMode: ScoreMode, scoreMode: ScoreMode,
shapeMode: ShapeMode, shapeMode: ShapeMode,
snapMode: SnapMode snapMode: SnapMode,
creatorUserId: number|null
): Promise<Game> { ): Promise<Game> {
const seed = Util.hash(gameId + ' ' + ts) const seed = Util.hash(gameId + ' ' + ts)
const rng = new Rng(seed) const rng = new Rng(seed)
return { return {
id: gameId, id: gameId,
creatorUserId,
rng: { type: 'Rng', obj: rng }, rng: { type: 'Rng', obj: rng },
puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode), puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode),
players: [], players: [],
@ -32,23 +34,25 @@ async function createGameObject(
} }
} }
async function createGame( async function createNewGame(
gameId: string, gameSettings: GameSettings,
targetTiles: number,
image: PuzzleCreationImageInfo,
ts: Timestamp, ts: Timestamp,
scoreMode: ScoreMode, creatorUserId: number
shapeMode: ShapeMode, ): Promise<string> {
snapMode: SnapMode let gameId;
): Promise<void> { do {
gameId = Util.uniqId()
} while (GameCommon.exists(gameId))
const gameObject = await createGameObject( const gameObject = await createGameObject(
gameId, gameId,
targetTiles, gameSettings.tiles,
image, gameSettings.image,
ts, ts,
scoreMode, gameSettings.scoreMode,
shapeMode, gameSettings.shapeMode,
snapMode gameSettings.snapMode,
creatorUserId
) )
GameLog.create(gameId, ts) GameLog.create(gameId, ts)
@ -56,16 +60,19 @@ async function createGame(
gameId, gameId,
Protocol.LOG_HEADER, Protocol.LOG_HEADER,
1, 1,
targetTiles, gameSettings.tiles,
image, gameSettings.image,
ts, ts,
scoreMode, gameSettings.scoreMode,
shapeMode, gameSettings.shapeMode,
snapMode gameSettings.snapMode,
gameObject.creatorUserId
) )
GameCommon.setGame(gameObject.id, gameObject) GameCommon.setGame(gameObject.id, gameObject)
GameStorage.setDirty(gameId) GameStorage.setDirty(gameId)
return gameId
} }
function addPlayer(gameId: string, playerId: string, ts: Timestamp): void { function addPlayer(gameId: string, playerId: string, ts: Timestamp): void {
@ -100,7 +107,7 @@ function handleInput(
export default { export default {
createGameObject, createGameObject,
createGame, createNewGame,
addPlayer, addPlayer,
handleInput, handleInput,
} }

View file

@ -90,6 +90,7 @@ const get = (
log[0][5] = DefaultScoreMode(log[0][5]) log[0][5] = DefaultScoreMode(log[0][5])
log[0][6] = DefaultShapeMode(log[0][6]) log[0][6] = DefaultShapeMode(log[0][6])
log[0][7] = DefaultSnapMode(log[0][7]) log[0][7] = DefaultSnapMode(log[0][7])
log[0][8] = log[0][8] || null // creatorUserId
} }
return log return log
} }
@ -100,4 +101,6 @@ export default {
exists, exists,
log: _log, log: _log,
get, get,
filename,
idxname,
} }

View file

@ -5,6 +5,7 @@ import Util, { logger } from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import { DATA_DIR } from './Dirs' import { DATA_DIR } from './Dirs'
import Time from './../common/Time' import Time from './../common/Time'
import Db from './Db'
const log = logger('GameStorage.js') const log = logger('GameStorage.js')
@ -15,8 +16,73 @@ function setDirty(gameId: string): void {
function setClean(gameId: string): void { function setClean(gameId: string): void {
delete dirtyGames[gameId] delete dirtyGames[gameId]
} }
function loadGamesFromDb(db: Db): void {
const gameRows = db.getMany('games')
for (const gameRow of gameRows) {
loadGameFromDb(db, gameRow.id)
}
}
function loadGames(): void { function loadGameFromDb(db: Db, gameId: string): void {
const gameRow = db.get('games', {id: gameId})
let game
try {
game = JSON.parse(gameRow.data)
} catch {
log.log(`[ERR] unable to load game from db ${gameId}`);
}
if (typeof game.puzzle.data.started === 'undefined') {
game.puzzle.data.started = gameRow.created
}
if (typeof game.puzzle.data.finished === 'undefined') {
game.puzzle.data.finished = gameRow.finished
}
if (!Array.isArray(game.players)) {
game.players = Object.values(game.players)
}
const gameObject: Game = storeDataToGame(game, game.creator_user_id)
GameCommon.setGame(gameObject.id, gameObject)
}
function persistGamesToDb(db: Db): void {
for (const gameId of Object.keys(dirtyGames)) {
persistGameToDb(db, gameId)
}
}
function persistGameToDb(db: Db, gameId: string): void {
const game: Game|null = GameCommon.get(gameId)
if (!game) {
log.error(`[ERROR] unable to persist non existing game ${gameId}`)
return
}
if (game.id in dirtyGames) {
setClean(game.id)
}
db.upsert('games', {
id: game.id,
creator_user_id: game.creatorUserId,
image_id: game.puzzle.info.image?.id,
created: game.puzzle.data.started,
finished: game.puzzle.data.finished,
data: gameToStoreData(game)
}, {
id: game.id,
})
log.info(`[INFO] persisted game ${game.id}`)
}
/**
* @deprecated
*/
function loadGamesFromDisk(): void {
const files = fs.readdirSync(DATA_DIR) const files = fs.readdirSync(DATA_DIR)
for (const f of files) { for (const f of files) {
const m = f.match(/^([a-z0-9]+)\.json$/) const m = f.match(/^([a-z0-9]+)\.json$/)
@ -24,11 +90,14 @@ function loadGames(): void {
continue continue
} }
const gameId = m[1] const gameId = m[1]
loadGame(gameId) loadGameFromDisk(gameId)
} }
} }
function loadGame(gameId: string): void { /**
* @deprecated
*/
function loadGameFromDisk(gameId: string): void {
const file = `${DATA_DIR}/${gameId}.json` const file = `${DATA_DIR}/${gameId}.json`
const contents = fs.readFileSync(file, 'utf-8') const contents = fs.readFileSync(file, 'utf-8')
let game let game
@ -49,39 +118,29 @@ function loadGame(gameId: string): void {
if (!Array.isArray(game.players)) { if (!Array.isArray(game.players)) {
game.players = Object.values(game.players) game.players = Object.values(game.players)
} }
const gameObject: Game = { const gameObject: Game = storeDataToGame(game, null)
id: game.id,
rng: {
type: game.rng ? game.rng.type : '_fake_',
obj: game.rng ? Rng.unserialize(game.rng.obj) : new Rng(0),
},
puzzle: game.puzzle,
players: game.players,
evtInfos: {},
scoreMode: DefaultScoreMode(game.scoreMode),
shapeMode: DefaultShapeMode(game.shapeMode),
snapMode: DefaultSnapMode(game.snapMode),
}
GameCommon.setGame(gameObject.id, gameObject) GameCommon.setGame(gameObject.id, gameObject)
} }
function persistGames(): void { function storeDataToGame(storeData: any, creatorUserId: number|null): Game {
for (const gameId of Object.keys(dirtyGames)) { return {
persistGame(gameId) id: storeData.id,
creatorUserId,
rng: {
type: storeData.rng ? storeData.rng.type : '_fake_',
obj: storeData.rng ? Rng.unserialize(storeData.rng.obj) : new Rng(0),
},
puzzle: storeData.puzzle,
players: storeData.players,
evtInfos: {},
scoreMode: DefaultScoreMode(storeData.scoreMode),
shapeMode: DefaultShapeMode(storeData.shapeMode),
snapMode: DefaultSnapMode(storeData.snapMode),
} }
} }
function persistGame(gameId: string): void { function gameToStoreData(game: Game): string {
const game = GameCommon.get(gameId) return JSON.stringify({
if (!game) {
log.error(`[ERROR] unable to persist non existing game ${gameId}`)
return
}
if (game.id in dirtyGames) {
setClean(game.id)
}
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({
id: game.id, id: game.id,
rng: { rng: {
type: game.rng.type, type: game.rng.type,
@ -92,14 +151,18 @@ function persistGame(gameId: string): void {
scoreMode: game.scoreMode, scoreMode: game.scoreMode,
shapeMode: game.shapeMode, shapeMode: game.shapeMode,
snapMode: game.snapMode, snapMode: game.snapMode,
})) });
log.info(`[INFO] persisted game ${game.id}`)
} }
export default { export default {
loadGames, // disk functions are deprecated
loadGame, loadGamesFromDisk,
persistGames, loadGameFromDisk,
persistGame,
loadGamesFromDb,
loadGameFromDb,
persistGamesToDb,
persistGameToDb,
setDirty, setDirty,
} }

View file

@ -6,31 +6,11 @@ import sharp from 'sharp'
import {UPLOAD_DIR, UPLOAD_URL} from './Dirs' import {UPLOAD_DIR, UPLOAD_URL} from './Dirs'
import Db, { OrderBy, WhereRaw } from './Db' import Db, { OrderBy, WhereRaw } from './Db'
import { Dim } from '../common/Geometry' import { Dim } from '../common/Geometry'
import { logger } from '../common/Util' import Util, { logger } from '../common/Util'
import { Timestamp } from '../common/Types' import { Tag, ImageInfo } from '../common/Types'
const log = logger('Images.ts') const log = logger('Images.ts')
interface Tag
{
id: number
slug: string
title: string
}
interface ImageInfo
{
id: number
filename: string
file: string
url: string
title: string
tags: Tag[]
created: Timestamp
width: number
height: number
}
const resizeImage = async (filename: string): Promise<void> => { const resizeImage = async (filename: string): Promise<void> => {
if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) { if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) {
return return
@ -105,8 +85,8 @@ const imageFromDb = (db: Db, imageId: number): ImageInfo => {
const i = db.get('images', { id: imageId }) const i = db.get('images', { id: imageId })
return { return {
id: i.id, id: i.id,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id), tags: getTags(db, i.id),
@ -151,8 +131,8 @@ inner join images i on i.id = ixc.image_id ${where.sql};
return images.map(i => ({ return images.map(i => ({
id: i.id as number, id: i.id as number,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id), tags: getTags(db, i.id),
@ -173,8 +153,8 @@ const allImagesFromDisk = (
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/)) .filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({ .map(f => ({
id: 0, id: 0,
uploaderUserId: null,
filename: f, filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`, url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: f.replace(/\.[a-z]+$/, ''), title: f.replace(/\.[a-z]+$/, ''),
tags: [] as Tag[], tags: [] as Tag[],
@ -186,13 +166,13 @@ const allImagesFromDisk = (
switch (sort) { switch (sort) {
case 'alpha_asc': case 'alpha_asc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file > b.file ? 1 : -1 return a.filename > b.filename ? 1 : -1
}) })
break; break;
case 'alpha_desc': case 'alpha_desc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file < b.file ? 1 : -1 return a.filename < b.filename ? 1 : -1
}) })
break; break;
@ -229,6 +209,20 @@ async function getDimensions(imagePath: string): Promise<Dim> {
} }
} }
const setTags = (db: Db, imageId: number, tags: string[]): void => {
db.delete('image_x_category', { image_id: imageId })
tags.forEach((tag: string) => {
const slug = Util.slug(tag)
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
}
})
}
export default { export default {
allImagesFromDisk, allImagesFromDisk,
imageFromDb, imageFromDb,
@ -236,4 +230,5 @@ export default {
getAllTags, getAllTags,
resizeImage, resizeImage,
getDimensions, getDimensions,
setTags,
} }

View file

@ -1,13 +1,9 @@
import Util from './../common/Util' import Util from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import Images from './Images' import Images from './Images'
import { EncodedPiece, EncodedPieceShape, PieceShape, Puzzle, ShapeMode } from '../common/Types' import { EncodedPiece, EncodedPieceShape, PieceShape, Puzzle, ShapeMode, ImageInfo } from '../common/Types'
import { Dim, Point } from '../common/Geometry' import { Dim, Point } from '../common/Geometry'
import { UPLOAD_DIR } from './Dirs'
export interface PuzzleCreationImageInfo {
file: string
url: string
}
export interface PuzzleCreationInfo { export interface PuzzleCreationInfo {
width: number width: number
@ -27,11 +23,11 @@ const TILE_SIZE = 64
async function createPuzzle( async function createPuzzle(
rng: Rng, rng: Rng,
targetTiles: number, targetTiles: number,
image: PuzzleCreationImageInfo, image: ImageInfo,
ts: number, ts: number,
shapeMode: ShapeMode shapeMode: ShapeMode
): Promise<Puzzle> { ): Promise<Puzzle> {
const imagePath = image.file const imagePath = `${UPLOAD_DIR}/${image.filename}`
const imageUrl = image.url const imageUrl = image.url
// determine puzzle information from the image dimensions // determine puzzle information from the image dimensions
@ -139,7 +135,8 @@ async function createPuzzle(
}, },
// information that was used to create the puzzle // information that was used to create the puzzle
targetTiles: targetTiles, targetTiles: targetTiles,
imageUrl, imageUrl, // todo: remove
image: image,
width: info.width, // actual puzzle width (same as bitmap.width) width: info.width, // actual puzzle width (same as bitmap.width)
height: info.height, // actual puzzle height (same as bitmap.height) height: info.height, // actual puzzle height (same as bitmap.height)

36
src/server/Users.ts Normal file
View file

@ -0,0 +1,36 @@
import Time from '../common/Time'
import Db from './Db'
const TABLE = 'users'
const HEADER_CLIENT_ID = 'client-id'
const HEADER_CLIENT_SECRET = 'client-secret'
const getOrCreateUser = (db: Db, req: any): any => {
let user = getUser(db, req)
if (!user) {
db.insert(TABLE, {
'client_id': req.headers[HEADER_CLIENT_ID],
'client_secret': req.headers[HEADER_CLIENT_SECRET],
'created': Time.timestamp(),
})
user = getUser(db, req)
}
return user
}
const getUser = (db: Db, req: any): any => {
const user = db.get(TABLE, {
'client_id': req.headers[HEADER_CLIENT_ID],
'client_secret': req.headers[HEADER_CLIENT_SECRET],
})
if (user) {
user.id = parseInt(user.id, 10)
}
return user
}
export default {
getOrCreateUser,
getUser,
}

View file

@ -19,9 +19,10 @@ import {
UPLOAD_DIR, UPLOAD_DIR,
} from './Dirs' } from './Dirs'
import GameCommon from '../common/GameCommon' import GameCommon from '../common/GameCommon'
import { ServerEvent, Game as GameType, GameSettings, ScoreMode, ShapeMode, SnapMode } from '../common/Types' import { ServerEvent, Game as GameType, GameSettings } from '../common/Types'
import GameStorage from './GameStorage' import GameStorage from './GameStorage'
import Db from './Db' import Db from './Db'
import Users from './Users'
const db = new Db(DB_FILE, DB_PATCHES_DIR) const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch() db.patch()
@ -57,6 +58,14 @@ const storage = multer.diskStorage({
}) })
const upload = multer({storage}).single('file'); const upload = multer({storage}).single('file');
app.get('/api/me', (req, res): void => {
let user = Users.getUser(db, req)
res.send({
id: user ? user.id : null,
created: user ? user.created : null,
})
})
app.get('/api/conf', (req, res): void => { app.get('/api/conf', (req, res): void => {
res.send({ res.send({
WS_ADDRESS: config.ws.connectstring, WS_ADDRESS: config.ws.connectstring,
@ -87,11 +96,12 @@ app.get('/api/replay-data', async (req, res): Promise<void> => {
game = await Game.createGameObject( game = await Game.createGameObject(
gameId, gameId,
log[0][2], log[0][2],
log[0][3], log[0][3], // must be ImageInfo
log[0][4], log[0][4],
log[0][5], log[0][5],
log[0][6], log[0][6],
log[0][7], log[0][7],
log[0][8], // creatorUserId
) )
} }
res.send({ log, game: game ? Util.encodeGame(game) : null }) res.send({ log, game: game ? Util.encodeGame(game) : null })
@ -133,32 +143,27 @@ interface SaveImageRequestData {
tags: string[] tags: string[]
} }
const setImageTags = (db: Db, imageId: number, tags: string[]): void => {
tags.forEach((tag: string) => {
const slug = Util.slug(tag)
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
}
})
}
app.post('/api/save-image', express.json(), (req, res): void => { app.post('/api/save-image', express.json(), (req, res): void => {
const user = Users.getUser(db, req)
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' })
return
}
const data = req.body as SaveImageRequestData const data = req.body as SaveImageRequestData
const image = db.get('images', {id: data.id})
if (parseInt(image.uploader_user_id, 10) !== user.id) {
res.status(403).send({ ok: false, error: 'forbidden' })
return
}
db.update('images', { db.update('images', {
title: data.title, title: data.title,
}, { }, {
id: data.id, id: data.id,
}) })
db.delete('image_x_category', { image_id: data.id }) Images.setTags(db, data.id, data.tags || [])
if (data.tags) {
setImageTags(db, data.id, data.tags)
}
res.send({ ok: true }) res.send({ ok: true })
}) })
@ -166,20 +171,25 @@ app.post('/api/upload', (req, res): void => {
upload(req, res, async (err: any): Promise<void> => { upload(req, res, async (err: any): Promise<void> => {
if (err) { if (err) {
log.log(err) log.log(err)
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!")
return
} }
try { try {
await Images.resizeImage(req.file.filename) await Images.resizeImage(req.file.filename)
} catch (err) { } catch (err) {
log.log(err) log.log(err)
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!")
return
} }
const user = Users.getOrCreateUser(db, req)
const dim = await Images.getDimensions( const dim = await Images.getDimensions(
`${UPLOAD_DIR}/${req.file.filename}` `${UPLOAD_DIR}/${req.file.filename}`
) )
const imageId = db.insert('images', { const imageId = db.insert('images', {
uploader_user_id: user.id,
filename: req.file.filename, filename: req.file.filename,
filename_original: req.file.originalname, filename_original: req.file.originalname,
title: req.body.title || '', title: req.body.title || '',
@ -190,7 +200,7 @@ app.post('/api/upload', (req, res): void => {
if (req.body.tags) { if (req.body.tags) {
const tags = req.body.tags.split(',').filter((tag: string) => !!tag) const tags = req.body.tags.split(',').filter((tag: string) => !!tag)
setImageTags(db, imageId as number, tags) Images.setTags(db, imageId as number, tags)
} }
res.send(Images.imageFromDb(db, imageId as number)) res.send(Images.imageFromDb(db, imageId as number))
@ -198,21 +208,12 @@ app.post('/api/upload', (req, res): void => {
}) })
app.post('/api/newgame', express.json(), async (req, res): Promise<void> => { app.post('/api/newgame', express.json(), async (req, res): Promise<void> => {
const gameSettings = req.body as GameSettings const user = Users.getOrCreateUser(db, req)
log.log(gameSettings) const gameId = await Game.createNewGame(
const gameId = Util.uniqId() req.body as GameSettings,
if (!GameCommon.exists(gameId)) { Time.timestamp(),
const ts = Time.timestamp() user.id
await Game.createGame(
gameId,
gameSettings.tiles,
gameSettings.image,
ts,
gameSettings.scoreMode,
gameSettings.shapeMode,
gameSettings.snapMode,
) )
}
res.send({ id: gameId }) res.send({ id: gameId })
}) })
@ -310,7 +311,7 @@ wss.on('message', async (
} }
}) })
GameStorage.loadGames() GameStorage.loadGamesFromDb(db)
const server = app.listen( const server = app.listen(
port, port,
hostname, hostname,
@ -333,7 +334,7 @@ memoryUsageHuman()
// persist games in fixed interval // persist games in fixed interval
const persistInterval = setInterval(() => { const persistInterval = setInterval(() => {
log.log('Persisting games...') log.log('Persisting games...')
GameStorage.persistGames() GameStorage.persistGamesToDb(db)
memoryUsageHuman() memoryUsageHuman()
}, config.persistence.interval) }, config.persistence.interval)
@ -345,7 +346,7 @@ const gracefulShutdown = (signal: string): void => {
clearInterval(persistInterval) clearInterval(persistInterval)
log.log('persisting games...') log.log('persisting games...')
GameStorage.persistGames() GameStorage.persistGamesToDb(db)
log.log('shutting down webserver...') log.log('shutting down webserver...')
server.close() server.close()