Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • commul/oetzit
1 result
Show changes
Commits on Source (25)
Showing
with 778 additions and 220 deletions
......@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2022-05-18
### Added
- `BE` Leaderboard endpoint (ensure no device id escept the user's is publicly circulating).
- `FE` Leaderboard scene with top players (everyone's id is hashed and anonymized, names are generated deterministically).
- `BE` Added email to devices.
- `FE` Added reward scene to input email.
- `BE` Top weekly players in dashboard.
- `BE` Dashboard is now password protected.
- `FE` Track personal best words/time/score.
- `FE` Notify when personal best is beaten upon game over.
- `FE` Link to privacy policy in rewards scene, abiding to GDPR.
### Changed
- `FE` Wrong casing is now accepted, halving the points of the wrong letters.
- `FE` Device handling is simplified and bubbled up to the `Game` instance itself.
- `FE` Refactor text style handling.
- `FE` Refactor button interaction handling.
### Fixed
- `FE` Fix english in tutorial.
- `FE` Fix text alignment in tutorial.
- `FE` Fix arrows emoji for mobile in tutorial.
- `FE` Fix wonky clue positioning in tutorial.
- `FE` Fix pause overlay when in game over screen.
- `FE` Disable focus pausing in reward scene, as prompts are tricky.
## [1.2.0] - 2022-05-11
### Added
......
......@@ -17,6 +17,8 @@ services:
- PORT=8080
- DATABASE_URL=postgres://db_user:db_pass@database/db_name
- APP_VERSION=development
- DASHBOARD_USERNAME=admin
- DASHBOARD_PASSWORD=admin
command: npm run serve
cli:
# docker-compose -f docker-compose.dev.yml run cli
......
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable("devices", (table) =>
table.string("email").nullable(),
);
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable("devices", (table) =>
table.dropColumn("email"),
);
}
......@@ -9,6 +9,8 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@fastify/auth": "^2.0.0",
"@fastify/basic-auth": "^3.0.2",
"@sinclair/typebox": "^0.23.4",
"@types/sharp": "^0.29.5",
"@xmldom/xmldom": "^0.8.1",
......@@ -674,6 +676,59 @@
"ajv": "^6.12.6"
}
},
"node_modules/@fastify/auth": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fastify/auth/-/auth-2.0.0.tgz",
"integrity": "sha512-86+vCuiAbbtOLf3d3n4p/+o425JPh2lpEigc6y3pYIIZgedZuWawYzGaN/R8J0br1jSmTURQbHfOzE0iQSbLWA==",
"dependencies": {
"fastify-plugin": "^3.0.0",
"reusify": "^1.0.4"
}
},
"node_modules/@fastify/basic-auth": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@fastify/basic-auth/-/basic-auth-3.0.2.tgz",
"integrity": "sha512-LCAhLRn4/CrJAS/ThZxNbT1FDpd1SyZD2lWnepJgvrZobytADlXDHtm/VRnJvqOfvlHsUzOfp5BrTPtcvw2h5w==",
"dependencies": {
"basic-auth": "^2.0.1",
"fastify-plugin": "^3.0.0",
"http-errors": "^2.0.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@fastify/basic-auth/node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@fastify/basic-auth/node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@fastify/basic-auth/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
......@@ -1985,6 +2040,22 @@
}
]
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
......@@ -8523,6 +8594,49 @@
"ajv": "^6.12.6"
}
},
"@fastify/auth": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fastify/auth/-/auth-2.0.0.tgz",
"integrity": "sha512-86+vCuiAbbtOLf3d3n4p/+o425JPh2lpEigc6y3pYIIZgedZuWawYzGaN/R8J0br1jSmTURQbHfOzE0iQSbLWA==",
"requires": {
"fastify-plugin": "^3.0.0",
"reusify": "^1.0.4"
}
},
"@fastify/basic-auth": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@fastify/basic-auth/-/basic-auth-3.0.2.tgz",
"integrity": "sha512-LCAhLRn4/CrJAS/ThZxNbT1FDpd1SyZD2lWnepJgvrZobytADlXDHtm/VRnJvqOfvlHsUzOfp5BrTPtcvw2h5w==",
"requires": {
"basic-auth": "^2.0.1",
"fastify-plugin": "^3.0.0",
"http-errors": "^2.0.0"
},
"dependencies": {
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
},
"http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"requires": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
}
},
"statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="
}
}
},
"@humanwhocodes/config-array": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
......@@ -9540,6 +9654,21 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"requires": {
"safe-buffer": "5.1.2"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
......
{
"name": "oetzit-backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "Ötzit! backend.",
"license": "MIT",
"author": {
......@@ -23,6 +23,8 @@
"watch:test": "jest --watch"
},
"dependencies": {
"@fastify/auth": "^2.0.0",
"@fastify/basic-auth": "^3.0.2",
"@sinclair/typebox": "^0.23.4",
"@types/sharp": "^0.29.5",
"@xmldom/xmldom": "^0.8.1",
......
......@@ -6,6 +6,7 @@ import { connection } from "./db";
import * as Types from "./types";
import * as Schemas from "./schemas";
import * as Crypto from "crypto";
// NOTE: refer to https://cloud.google.com/apis/design/
// NOTE: see https://www.npmjs.com/package/fastify-plugin for TS plugin definition
......@@ -84,6 +85,54 @@ const apiPlugin: FastifyPluginCallback = (fastify, options, next) => {
});
fastify.route<{
Body: Types.LeaderboardQuery;
Reply: Types.LeaderboardSafeView;
}>({
method: "POST",
url: "/devices/leaderboard",
schema: {
body: Schemas.LeaderboardQuery,
response: {
200: Schemas.LeaderboardSafeView,
},
},
handler: async (request, reply) => {
const leaderboardView: Types.LeaderboardView = await connection
.select(
connection.raw(
"ROW_NUMBER() OVER(ORDER BY MAX(score) DESC) AS place, device_id, max(score) AS game_score",
),
)
.from("games")
.whereNotNull("score")
.groupBy<Types.LeaderboardView>("device_id")
.orderBy("game_score", "DESC");
const deviceIndex = leaderboardView.findIndex(
({ device_id }) => device_id == request.body.device_id,
);
const filteredLeaderboardView = leaderboardView.filter(
({ place }, index) => place < 4 || Math.abs(deviceIndex - index) < 2,
);
const hashedLeaderboardView: Types.LeaderboardSafeView =
filteredLeaderboardView.map((item) => {
return {
place: item.place,
game_score: item.game_score,
device_hash: Crypto.createHash("sha256")
.update(item.device_id)
.digest("hex"),
};
});
reply.code(200).send(hashedLeaderboardView);
},
});
fastify.route<{
Body: Types.LeaderboardView;
Reply: Types.Device;
}>({
method: "POST",
......@@ -103,6 +152,29 @@ const apiPlugin: FastifyPluginCallback = (fastify, options, next) => {
},
});
fastify.route<{
Params: IdInParamsType;
Body: Types.DeviceUpdate;
Reply: Types.Device;
}>({
method: "PATCH",
url: "/devices/:id",
schema: {
params: IdInParamsSchema,
body: Schemas.DeviceUpdate,
response: {
200: Schemas.Device,
},
},
handler: async (request, reply) => {
const devices = await connection<Types.Device>("devices")
.where("id", request.params.id)
.update(request.body)
.returning("*");
reply.code(200).send(devices[0]);
},
});
fastify.route<{
Params: IdInParamsType;
Reply: Types.Game;
......
import { FastifyPluginCallback } from "fastify";
import { connection } from "./db";
// NOTE: refer to https://cloud.google.com/apis/design/
// NOTE: see https://www.npmjs.com/package/fastify-plugin for TS plugin definition
//==============================================================================
const dashboardPlugin: FastifyPluginCallback = (fastify, options, next) => {
fastify.route({
method: "GET",
url: "/",
preHandler: fastify.auth([fastify.basicAuth]),
handler: async (request, reply) => {
// reply.code(200).send("Hello, World!");
const devicesCount = (await connection.table("devices").count())[0].count;
const gamesCount = (await connection.table("games").count())[0].count;
const cluesCount = (await connection.table("clues").count())[0].count;
const shotsCount = (await connection.table("shots").count())[0].count;
const normalizedGames = connection
.table("games")
.select(
connection.raw(
"games.id, games.device_id, games.began_at_gmtm, games.ended_at_gmtm, max(shots.ended_at_gmtm) as last_shot_ended_at_gmtm",
),
)
.fullOuterJoin("shots", "games.id", "shots.game_id")
.groupBy("games.id");
const devicesBehaviour = await connection
.select(
connection.raw(
"sum(coalesce (ended_at_gmtm, last_shot_ended_at_gmtm) - began_at_gmtm) as time_spent, count(case when ended_at_gmtm is not null then 1 end) as ended_count, count(case when ended_at_gmtm is null then 1 end) as interrupted_count, device_id",
),
)
.from(normalizedGames.as("g"))
.groupBy("device_id");
const wordsPerformance = await connection
.select(
connection.raw(
"words.id, words.ocr_transcript, words.ocr_confidence, AVG(shots.similarity) as avg_similarity",
),
)
.from("shots")
.join("clues", "clues.id", "shots.clue_id")
.join("words", "words.id", "clues.word_id")
.whereNotNull("shots.similarity")
.groupBy("words.id");
const devicesByDate = await connection
.table("games")
.select(connection.raw("COUNT(DISTINCT device_id), DATE(began_at)"))
.whereNotNull("device_id")
.groupByRaw("DATE(began_at)");
const gamesByDate = await connection
.table("games")
.select(
connection.raw(
"COUNT(*), DATE(began_at), ended_at IS NOT NULL as ended",
),
)
.groupByRaw("DATE(began_at), ended");
const shotsByDuration = await connection
.table("shots")
.select(
connection.raw(
"width_bucket(ended_at_gmtm - began_at_gmtm, 0, 60*1000, 60*5)*200 as bucket, count(*)",
),
)
.groupBy("bucket")
.orderBy("bucket");
const bestWeeklyScoresByDevice = connection
.table("games")
.select(
connection.raw(
"device_id, DATE_PART('week', ended_at) as week, MAX(score) as best_score",
),
)
.whereNotNull("score")
.groupBy("device_id", "week");
const rankedWeeklyScoresByDevice = connection
.select(
connection.raw(
"*, RANK() OVER(PARTITION BY week ORDER BY best_score DESC) as week_rank",
),
)
.from(bestWeeklyScoresByDevice.as("g"));
const topWeeklyPlayers = await connection
.select("ranked.*", "devices.email")
.from(rankedWeeklyScoresByDevice.as("ranked"))
.join("devices", "devices.id", "ranked.device_id")
.where("week_rank", "<=", 3)
.orderBy("week", "desc")
.orderBy("week_rank", "asc");
reply.view("/templates/dashboard.ejs", {
appVersion: process.env.APP_VERSION || "unknown",
devicesCount,
gamesCount,
cluesCount,
shotsCount,
devicesByDate,
gamesByDate,
shotsByDuration,
devicesBehaviour,
wordsPerformance,
topWeeklyPlayers,
});
},
});
next();
};
export default dashboardPlugin;
import fastify from "fastify";
import fastifyAuth from "@fastify/auth";
import fastifyBasicAuth from "@fastify/basic-auth";
import fastifyCors from "fastify-cors";
import fastifySwagger from "fastify-swagger";
import fastifyRollbar from "./rollbar_plugin";
......@@ -45,6 +47,18 @@ server.register(fastifySwagger, {
},
});
server.register(fastifyAuth);
server.register(fastifyBasicAuth, {
authenticate: true,
validate: async function (username, password) {
const user = process.env.DASHBOARD_USERNAME;
const pass = process.env.DASHBOARD_PASSWORD;
const userKo = !user || user !== username;
const passKo = !pass || pass !== password;
if (userKo || passKo) return new Error("Unauthorized");
},
});
import apiRoutes from "./api";
server.register(apiRoutes, { prefix: "api" });
......@@ -57,83 +71,8 @@ server.register(pointOfView, {
},
});
import { connection } from "./db";
server.get("/", async (request, reply) => {
// reply.code(200).send("Hello, World!");
const devicesCount = (await connection.table("devices").count())[0].count;
const gamesCount = (await connection.table("games").count())[0].count;
const cluesCount = (await connection.table("clues").count())[0].count;
const shotsCount = (await connection.table("shots").count())[0].count;
const normalizedGames = connection
.table("games")
.select(
connection.raw(
"games.id, games.device_id, games.began_at_gmtm, games.ended_at_gmtm, max(shots.ended_at_gmtm) as last_shot_ended_at_gmtm",
),
)
.fullOuterJoin("shots", "games.id", "shots.game_id")
.groupBy("games.id");
const devicesBehaviour = await connection
.select(
connection.raw(
"sum(coalesce (ended_at_gmtm, last_shot_ended_at_gmtm) - began_at_gmtm) as time_spent, count(case when ended_at_gmtm is not null then 1 end) as ended_count, count(case when ended_at_gmtm is null then 1 end) as interrupted_count, device_id",
),
)
.from(normalizedGames.as("g"))
.groupBy("device_id");
const wordsPerformance = await connection
.select(
connection.raw(
"words.id, words.ocr_transcript, words.ocr_confidence, AVG(shots.similarity) as avg_similarity",
),
)
.from("shots")
.join("clues", "clues.id", "shots.clue_id")
.join("words", "words.id", "clues.word_id")
.whereNotNull("shots.similarity")
.groupBy("words.id");
const devicesByDate = await connection
.table("games")
.select(connection.raw("COUNT(DISTINCT device_id), DATE(began_at)"))
.whereNotNull("device_id")
.groupByRaw("DATE(began_at)");
const gamesByDate = await connection
.table("games")
.select(
connection.raw("COUNT(*), DATE(began_at), ended_at IS NOT NULL as ended"),
)
.groupByRaw("DATE(began_at), ended");
const shotsByDuration = await connection
.table("shots")
.select(
connection.raw(
"width_bucket(ended_at_gmtm - began_at_gmtm, 0, 60*1000, 60*5)*200 as bucket, count(*)",
),
)
.groupBy("bucket")
.orderBy("bucket");
reply.view("/templates/dashboard.ejs", {
appVersion: process.env.APP_VERSION || "unknown",
devicesCount,
gamesCount,
cluesCount,
shotsCount,
devicesByDate,
gamesByDate,
shotsByDuration,
devicesBehaviour,
wordsPerformance,
});
});
import dashboardRoutes from "./dashboard";
server.register(dashboardRoutes);
// TODO: this is an horrible kludge
import fastifyStatic from "fastify-static";
......
......@@ -13,7 +13,9 @@ export const Word = Type.Object({
export const Device = Type.Object({
id: Type.Readonly(Type.String({ format: "uuid" })),
email: Nullable(Type.String({ format: "email" })),
});
export const DeviceUpdate = Type.Partial(Type.Pick(Device, ["email"]));
export const Game = Type.Object({
id: Type.Readonly(Type.String({ format: "uuid" })),
......@@ -84,3 +86,23 @@ export const WordChoice = Type.Object({
ocr_transcript_length_min: Type.Integer({ minimum: 1, default: 1 }),
ocr_transcript_length_max: Type.Integer({ minimum: 1, default: 20 }),
});
export const LeaderboardQuery = Type.Object({
device_id: Type.Optional(Type.String({ format: "uuid" })),
});
export const LeaderboardItem = Type.Object({
place: Type.Integer({ minimum: 1 }),
device_id: Type.String({ format: "uuid" }),
game_score: Type.Integer(),
});
export const LeaderboardView = Type.Array(LeaderboardItem);
export const LeaderboardSafeItem = Type.Object({
place: Type.Integer({ minimum: 1 }),
device_hash: Type.String({ pattern: "^[0-9a-z]{64}$" }),
game_score: Type.Integer(),
});
export const LeaderboardSafeView = Type.Array(LeaderboardSafeItem);
......@@ -2,16 +2,25 @@ import { Static } from "@sinclair/typebox";
import * as Schemas from "./schemas";
export type Word = Static<typeof Schemas.Word>;
export type Device = Static<typeof Schemas.Device>;
export type Game = Static<typeof Schemas.Game>;
export type Clue = Static<typeof Schemas.Clue>;
export type Shot = Static<typeof Schemas.Shot>;
export type DeviceUpdate = Static<typeof Schemas.DeviceUpdate>;
export type Game = Static<typeof Schemas.Game>;
export type GameCreate = Static<typeof Schemas.GameCreate>;
export type GameUpdate = Static<typeof Schemas.GameUpdate>;
export type Clue = Static<typeof Schemas.Clue>;
export type ClueCreate = Static<typeof Schemas.ClueCreate>;
export type ClueUpdate = Static<typeof Schemas.ClueUpdate>;
export type Shot = Static<typeof Schemas.Shot>;
export type ShotCreate = Static<typeof Schemas.ShotCreate>;
export type Word = Static<typeof Schemas.Word>;
export type WordChoice = Static<typeof Schemas.WordChoice>;
export type LeaderboardQuery = Static<typeof Schemas.LeaderboardQuery>;
export type LeaderboardItem = Static<typeof Schemas.LeaderboardItem>;
export type LeaderboardView = Static<typeof Schemas.LeaderboardView>;
export type LeaderboardSafeItem = Static<typeof Schemas.LeaderboardSafeItem>;
export type LeaderboardSafeView = Static<typeof Schemas.LeaderboardSafeView>;
......@@ -99,6 +99,47 @@
<script id="wordsPerformanceData"
type="application/json"><%- JSON.stringify(wordsPerformance) %></script>
<canvas id="wordsPerformanceChart"></canvas>
<h1>Top weekly players</h1>
<table class="table table-sm table-borderless table-striped">
<tbody>
<colgroup>
<col style="width:8%">
<col style="width:8%">
<col style="width:14%">
<col style="width:70%">
</colgroup>
<thead>
<tr>
<th scope="col" class="text-end">Week</th>
<th scope="col" class="text-end">Rank</th>
<th scope="col" class="text-end">Score</th>
<th scope="col">Player email</th>
</tr>
</thead>
<% for(var i=0; i < topWeeklyPlayers.length; i++) { %>
<tr
style="<%= topWeeklyPlayers[i].week != topWeeklyPlayers[i-1]?.week ? 'border-top: 2px solid black' : '' %>">
<th scope="row" class="text-end">
<%= topWeeklyPlayers[i].week %>
</th>
<td class="text-end">
<%= topWeeklyPlayers[i].week_rank %>
</td>
<td class="text-end">
<%= topWeeklyPlayers[i].best_score %>
</td>
<td>
<span title="<%= topWeeklyPlayers[i].device_id %>">
<%= topWeeklyPlayers[i].email ?? "N/A" %>
</span>
</td>
</tr>
<% } %>
</tbody>
</table>
</main>
</div>
</div>
......
......@@ -14,7 +14,8 @@
"newton-raphson-method": "^1.0.2",
"phaser": "^3.55.2",
"rollbar": "^2.24.1",
"simple-keyboard": "^3.4.68"
"simple-keyboard": "^3.4.68",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@parcel/transformer-sass": "^2.4.1",
......@@ -7456,6 +7457,14 @@
"node": ">=4.2.0"
}
},
"node_modules/unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
"engines": {
"node": ">=8"
}
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
......@@ -13106,6 +13115,11 @@
"integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
"dev": true
},
"unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
......
{
"name": "oetzit-backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "Ötzit! frontend.",
"license": "MIT",
"author": {
......@@ -27,7 +27,8 @@
"newton-raphson-method": "^1.0.2",
"phaser": "^3.55.2",
"rollbar": "^2.24.1",
"simple-keyboard": "^3.4.68"
"simple-keyboard": "^3.4.68",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@parcel/transformer-sass": "^2.4.1",
......
......@@ -18,6 +18,10 @@ export default {
createDevice: () => backend.post<Types.Device>("/api/devices"),
getDevice: (deviceId: string) =>
backend.get<Types.Device>(`/api/devices/${deviceId}`),
updateDevice: (deviceId: string, data: Types.DeviceUpdate) =>
backend.patch<Types.Device>(`/api/devices/${deviceId}`, data),
createLeaderboardView: (data: Types.LeaderboardQuery) =>
backend.post<Types.LeaderboardSafeItem[]>("/api/devices/leaderboard", data),
createWordChoice: (data: Types.WordChoice) =>
backend.post<Types.Word>("/api/words/choice", data),
createGame: (deviceId: string, data: Types.GameCreate) =>
......
import "phaser";
import { Word } from "../../../backend/src/types";
import TEXT_STYLES from "./text_styles";
interface CluePayload {
baseHeight: number;
......@@ -10,71 +11,18 @@ interface CluePayload {
//=[ Text clues ]===============================================================
import { FONTS } from "./assets";
const GERMAN_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜẞ" + "abcdefghijklmnopqrstuvwxyzäöüß";
export const TEXT_STYLE: {
[key: string]: (height: number) => Phaser.Types.GameObjects.Text.TextStyle;
} = {
TYPEWRITER: (height) => {
return {
fontSize: `${height * 1.4}px`,
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "white",
stroke: "black",
strokeThickness: 8,
testString: GERMAN_ALPHABET,
};
},
TRAINING: (height) => {
return {
fontSize: `${height * 1.4}px`,
fontFamily: FONTS.FRAK,
color: "white",
stroke: "black",
strokeThickness: 8,
testString: GERMAN_ALPHABET,
};
},
MONO_NEWSPAPER: (height) => {
return {
fontSize: `${height * 1.4}px`,
fontFamily: FONTS.MONO,
color: "#333333",
stroke: "#666666",
strokeThickness: 4,
testString: GERMAN_ALPHABET,
backgroundColor: "#aaaaaa",
padding: { x: 8 },
};
},
FRAK_NEWSPAPER: (height) => {
return {
fontSize: `${height * 1.4}px`,
fontFamily: FONTS.FRAK,
color: "#333333",
stroke: "#666666",
strokeThickness: 4,
testString: GERMAN_ALPHABET,
backgroundColor: "#aaaaaa",
padding: { x: 8 },
};
},
};
export class TextCluePayload
extends Phaser.GameObjects.Text
implements CluePayload
{
baseHeight: number;
textStyle = TEXT_STYLE.TYPEWRITER;
textStyle = TEXT_STYLES.CLUE_DEFAULT;
constructor(scene: Phaser.Scene, baseHeight: number) {
super(scene, 0, 0, "", TextCluePayload.prototype.textStyle(baseHeight));
super(scene, 0, 0, "", TextCluePayload.prototype.textStyle);
this.setFontSize(baseHeight * 1.4);
this.setAlpha(0);
this.setOrigin(0.5, 0.5);
this.baseHeight = baseHeight;
}
......
import MainScene, { InputStatus } from "./main_scene";
import Foe from "./foe";
import Game from "./game";
import Spear from "./spear";
import backend from "./backend";
import BackgroundScene from "./background_scene";
......@@ -13,13 +14,11 @@ import {
sawtoothRamp,
} from "./utils";
const DEVICE_KEY = "OETZIT/DEVICE_ID";
export default class FightScene extends MainScene {
game!: Game;
tapoutEnabled = true;
typewriterEnabled = true;
beDevice: Types.Device;
beGame: Types.Game;
spawner: Phaser.Time.TimerEvent;
......@@ -32,7 +31,6 @@ export default class FightScene extends MainScene {
this.planMusicChanges();
this.planWaveAnnouncements();
await this.initBeDevice();
await this.initBeGame();
this.spawnFoes();
......@@ -105,19 +103,9 @@ export default class FightScene extends MainScene {
//=[ BE initialization ]======================================================
async initBeDevice() {
const deviceId = sessionStorage.getItem(DEVICE_KEY);
if (deviceId === null) {
this.beDevice = (await backend.createDevice()).data;
} else {
this.beDevice = (await backend.getDevice(deviceId)).data;
}
sessionStorage.setItem(DEVICE_KEY, this.beDevice.id);
}
async initBeGame() {
this.beGame = (
await backend.createGame(this.beDevice.id, {
await backend.createGame(this.game.beDevice.id, {
began_at: new Date().toISOString(),
began_at_gmtm: this.getGameTime(),
})
......@@ -129,26 +117,28 @@ export default class FightScene extends MainScene {
submitTranscription = (inputStatus: InputStatus) => {
const similarityThreshold = 0.9;
// NOTE: this ain't async to avoid any UX delay
const { similarity, match } = this.findMatchingFoe(inputStatus.final);
const { match, casefullLevenshtein, caselessLevenshtein } =
this.findMatchingFoe(inputStatus.final);
let score = 0;
if (match === null) {
score = 0;
} else if (similarity < similarityThreshold) {
} else if (caselessLevenshtein.similarity < similarityThreshold) {
score = -1;
} else {
const lengthScore = nthFibonacci(1 + match.beWord.ocr_transcript.length);
const accuracyBonus = similarity;
const accuracyMalus =
(casefullLevenshtein.similarity + caselessLevenshtein.similarity) / 2;
const speedBonus =
2 -
(inputStatus.ended_at_gmtm - match.beClue.began_at_gmtm) /
(match.duration * 1000);
score = Math.round(lengthScore * accuracyBonus * speedBonus);
score = Math.round(lengthScore * accuracyMalus * speedBonus);
}
backend.createShot(this.beGame.id, {
clue_id: match?.beClue?.id || null,
similarity: similarity,
similarity: casefullLevenshtein.similarity,
score: score,
...inputStatus,
});
......@@ -157,7 +147,7 @@ export default class FightScene extends MainScene {
// NOOP
this.sound.play("sfx_md_beep");
this.hud.showSubmitFeedback("white", inputStatus.final);
} else if (similarity < similarityThreshold) {
} else if (caselessLevenshtein.similarity < similarityThreshold) {
// TODO: visual near misses based on score
this.sound.play("sfx_lo_beep");
this.updateScore(score);
......
......@@ -7,6 +7,13 @@ import FightScene from "./fight_scene";
import GameOverScene from "./game_over_scene";
import PauseScene from "./pause_scene";
import TutorialScene from "./tutorial_scene";
import LeaderboardScene from "./leaderboard_scene";
import RewardsScene from "./rewards_scene";
import * as Types from "../../../backend/src/types";
import backend from "./backend";
const DEVICE_ID_KEY = "OETZIT/DEVICE_ID";
export const GRAVITY_Y = 200;
......@@ -29,15 +36,30 @@ const CONFIG = {
WelcomeScene,
TutorialScene,
FightScene,
PauseScene,
GameOverScene,
LeaderboardScene,
RewardsScene,
PauseScene, // NOTE: keep this as last for overlaying
],
};
export default class Game extends Phaser.Game {
beDevice!: Types.Device;
constructor() {
super(CONFIG);
this.bindFocusEvents();
this.events.on(Phaser.Core.Events.READY, this.initBeDevice.bind(this));
}
async initBeDevice() {
const deviceId = sessionStorage.getItem(DEVICE_ID_KEY);
if (deviceId === null) {
this.beDevice = (await backend.createDevice()).data;
} else {
this.beDevice = (await backend.getDevice(deviceId)).data;
}
sessionStorage.setItem(DEVICE_ID_KEY, this.beDevice.id);
}
bindFocusEvents() {
......
import "phaser";
import { FONTS } from "./assets";
import BackgroundScene from "./background_scene";
import { formatTime, ICONS, THIN_SPACE } from "./hud";
import { formatTime, ICONS } from "./hud";
import TEXT_STYLES, { makeButtonHoverable } from "./text_styles";
const SS_KEYS = {
BEST_WORDS: "OETZIT/BEST_WORDS",
BEST_SCORE: "OETZIT/BEST_SCORE",
BEST_TIMER: "OETZIT/BEST_TIMER",
};
export default class GameOverScene extends Phaser.Scene {
music!: Phaser.Sound.BaseSound;
continueButton!: Phaser.GameObjects.Text;
beatScore!: boolean;
beatTimer!: boolean;
beatWords!: boolean;
constructor() {
super("game_over");
......@@ -37,36 +48,48 @@ export default class GameOverScene extends Phaser.Scene {
data.music,
);
this.processRecords(data.score, data.time, data.words);
this.drawTitle();
this.drawSubtitle(data.words);
this.drawResult(data.score, data.time);
this.drawSubtitle();
this.drawResult(data.words, data.score, data.time);
this.drawCTA();
this.bindEvents();
}
processRecords(score: number, timer: number, words: number) {
const bestScore = sessionStorage.getItem(SS_KEYS.BEST_SCORE);
const bestTimer = sessionStorage.getItem(SS_KEYS.BEST_TIMER);
const bestWords = sessionStorage.getItem(SS_KEYS.BEST_WORDS);
this.beatScore = bestScore === null || parseInt(bestScore) < score;
this.beatTimer = bestTimer === null || parseInt(bestTimer) < timer;
this.beatWords = bestWords === null || parseInt(bestWords) < words;
sessionStorage.setItem(SS_KEYS.BEST_SCORE, score.toString());
sessionStorage.setItem(SS_KEYS.BEST_TIMER, timer.toString());
sessionStorage.setItem(SS_KEYS.BEST_WORDS, words.toString());
}
drawTitle() {
const text = "GAME OVER";
const title = this.add.text(0, 0, text, {
...TEXT_STYLES.BASE,
fontSize: "48px",
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "#ff0000",
testString: text,
});
title.setOrigin(0.5, 1);
title.setOrigin(0.5, 0);
title.setPosition(
this.cameras.main.width * 0.5,
this.cameras.main.height * 0.2,
this.cameras.main.height * 0.1,
);
}
drawSubtitle(wordCount = 1234) {
const text = `You donated to our research\n${wordCount} WORDS\n${ICONS.HEALTH}${THIN_SPACE}Thank you!${THIN_SPACE}${ICONS.HEALTH}`; //
drawSubtitle() {
const text = `You contributed\n${ICONS.HEALTH} to our research! ${ICONS.HEALTH}\nThank you!`; //
const subtitle = this.add.text(0, 0, text, {
...TEXT_STYLES.BASE,
fontSize: "28px",
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "#aaff00",
align: "center",
testString: text,
});
subtitle.setOrigin(0.5, 0);
......@@ -76,45 +99,72 @@ export default class GameOverScene extends Phaser.Scene {
);
}
drawResult(score = 12345678, time = 12 * 60 * 1000 + 34 * 1000 + 56 * 10) {
const timer = formatTime(time);
const text = `${score}\u2009${ICONS.SCORE}\n${timer}\u2009${ICONS.CLOCK}`;
formatResult(words: number, score: number, time: number) {
let wordsLabel = words.toString();
let scoreLabel = score.toString();
let timerLabel = formatTime(time);
const labelWidth = Math.max(
wordsLabel.length,
scoreLabel.length,
timerLabel.length,
);
wordsLabel = wordsLabel.padStart(labelWidth, " ") + " 🔤";
scoreLabel = scoreLabel.padStart(labelWidth, " ") + " " + ICONS.SCORE;
timerLabel = timerLabel.padStart(labelWidth, " ") + " " + ICONS.CLOCK;
if (this.beatScore) scoreLabel += " 🏅";
if (this.beatTimer) timerLabel += " 🏅";
if (this.beatWords) wordsLabel += " 🏅";
return [wordsLabel, scoreLabel, timerLabel].join("\n");
}
drawResult(
words = 1234,
score = 12345678,
time = 12 * 60 * 1000 + 34 * 1000 + 56 * 10,
) {
const text = this.formatResult(words, score, time);
const title = this.add.text(0, 0, text, {
...TEXT_STYLES.BASE,
fontSize: "28px",
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "#ffffff",
align: "right",
testString: text,
});
title.setOrigin(0.5, 1);
title.setOrigin(0.5, 0);
title.setPosition(
this.cameras.main.width * 0.5,
this.cameras.main.height * 0.8 - 16,
this.cameras.main.height * 0.5,
);
if (this.beatScore || this.beatTimer || this.beatWords) {
const newPB = this.add.text(0, 0, "You set new records!", {
...TEXT_STYLES.BASE,
fontSize: "28px",
testString: text,
});
newPB.setOrigin(0.5, 0);
newPB.setPosition(
this.cameras.main.width * 0.5,
title.getBounds().bottom + 16,
);
}
}
drawCTA() {
const text = "press to continue";
const cta = this.add.text(0, 0, text, {
fontSize: "32px",
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "#ffffff",
});
cta.setOrigin(0.5, 0);
cta.setPosition(
this.cameras.main.width * 0.5,
this.cameras.main.height * 0.8,
);
const verb = this.game.device.os.desktop ? "Click" : "Tap";
const text = `${verb} to continue`;
this.continueButton = this.add
.text(this.cameras.main.centerX, this.cameras.main.height * 0.9, text, {
...TEXT_STYLES.BUTTON,
fontSize: "32px",
})
.setOrigin(0.5, 1)
.setPadding(4);
makeButtonHoverable(this.continueButton);
}
bindEvents() {
this.input.keyboard.once("keyup", this.startFight.bind(this));
this.input.once("pointerup", this.startFight.bind(this));
this.continueButton.on("pointerup", this.backToWelcome.bind(this));
}
startFight() {
backToWelcome() {
(this.scene.get("background") as BackgroundScene).liftCurtain();
this.scene.start("welcome", { music: this.music });
}
......
import { FONTS } from "./assets";
import TEXT_STYLES from "./text_styles";
export const ICONS = {
SCORE: "️⭐️",
......@@ -8,22 +8,6 @@ export const ICONS = {
export const THIN_SPACE = "\u2009";
const STATS_BASE_TEXT_STYLE = {
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "white",
testString: `${Object.values(ICONS).join("")}1234567890:.`,
stroke: "black",
strokeThickness: 4,
} as Phaser.Types.GameObjects.Text.TextStyle;
const INPUT_BASE_TEXT_STYLE = {
fontFamily: FONTS.MONO,
fontStyle: "bold",
color: "white",
testString: `ABCDEFGHIJKLMNOPQRSTUVWXYZÄÜÖẞabcdefghijklmnopqrstuvwxyzäüöß `,
} as Phaser.Types.GameObjects.Text.TextStyle;
interface HudOptions {
statsPadding: number;
statsFontSize: string;
......@@ -81,7 +65,8 @@ export default class HUD {
inputTextStyle(): Phaser.Types.GameObjects.Text.TextStyle {
return {
...INPUT_BASE_TEXT_STYLE,
...TEXT_STYLES.HUD_INPUT,
testString: `ABCDEFGHIJKLMNOPQRSTUVWXYZÄÜÖẞabcdefghijklmnopqrstuvwxyzäüöß`,
fontSize: this.options.inputFontSize,
padding: {
x: this.options.inputPadding,
......@@ -92,7 +77,8 @@ export default class HUD {
statsTextStyle(): Phaser.Types.GameObjects.Text.TextStyle {
return {
...STATS_BASE_TEXT_STYLE,
...TEXT_STYLES.HUD_STAT,
testString: `${Object.values(ICONS).join("")}1234567890:.`,
fontSize: this.options.statsFontSize,
padding: {
x: this.options.statsPadding,
......
import "phaser";
import { uniqueNamesGenerator, names } from "unique-names-generator";
import backend from "./backend";
import {
LeaderboardSafeItem,
LeaderboardSafeView,
} from "../../../backend/src/types";
import Game from "./game";
import { sha256 } from "./utils";
import TEXT_STYLES, { makeButtonHoverable } from "./text_styles";
const MEDALS = ["🥇", "🥈", "🥉"];
export default class LeaderboardScene extends Phaser.Scene {
game!: Game;
music!: Phaser.Sound.BaseSound;
rankings!: Phaser.GameObjects.Text;
message!: Phaser.GameObjects.Text;
backBtn!: Phaser.GameObjects.Text;
leaderboardView!: LeaderboardSafeView;
currentDeviceIndex!: number;
constructor() {
super("leaderboard");
}
async create(data: { music: Phaser.Sound.BaseSound }) {
this.music = data.music;
this.leaderboardView = await this.fetchLeaderboardView();
this.currentDeviceIndex = await this.calculateCurrentDeviceIndex();
this.createRankings();
this.createMessage();
this.createBackBtn();
this.bindEvents();
}
async fetchLeaderboardView() {
return (
await backend.createLeaderboardView({
device_id: this.game.beDevice.id ?? undefined,
})
).data;
}
async calculateCurrentDeviceIndex() {
const deviceHash = await sha256(this.game.beDevice.id);
return this.leaderboardView.findIndex(
({ device_hash }) => device_hash == deviceHash,
);
}
async createRankings() {
const renderedRankings = this.renderRankings(this.leaderboardView);
const fontSize = Math.min(this.cameras.main.height * 0.25, 24);
this.rankings = this.add
.text(0, 0, renderedRankings, {
...TEXT_STYLES.BASE,
testString: renderedRankings,
})
.setFontSize(fontSize)
.setOrigin(0.5, 0)
.setPosition(this.cameras.main.centerX, this.cameras.main.height * 0.1);
}
renderRankings(leaderboardItems: LeaderboardSafeItem[]) {
const output: [string, string, string, string][] = [];
// Render basic properties
leaderboardItems.forEach((entry) => {
output.push([
entry.place.toString(),
this.deterministicNameFromHash(entry.device_hash),
entry.game_score.toString(),
entry.place < 4 ? MEDALS[entry.place - 1] : "",
]);
});
// Find and mark current device
if (this.currentDeviceIndex > -1)
output[this.currentDeviceIndex][1] = "> YOU <";
// Add spacers
for (let i = leaderboardItems.length - 1; i > 1; i--) {
const thereIsAGap =
leaderboardItems[i].place - leaderboardItems[i - 1].place > 1;
if (thereIsAGap) output.splice(i, 0, ["", "...", "", ""]);
}
output.push(["", "...", "", ""]);
this.padColumns(output);
return output.map((i) => i.join(" ")).join("\n");
}
padColumns(renderedItems: [string, string, string, string][]) {
const maxWidth = Array(4)
.fill(0)
.map((_, i) =>
renderedItems.reduce(
(max, cur) => Math.max(max, cur[i].length),
-Infinity,
),
);
renderedItems.forEach((c) => (c[0] = c[0].padStart(maxWidth[0], " ")));
renderedItems.forEach((c) => (c[1] = c[1].padEnd(maxWidth[1], " ")));
renderedItems.forEach((c) => (c[2] = c[2].padStart(maxWidth[2], " ")));
}
deterministicNameFromHash(hash: string) {
//TODO
return uniqueNamesGenerator({
dictionaries: [names],
seed: parseInt(hash.slice(-12), 16),
});
}
createMessage() {
const renderedMessage = this.renderMessage();
const fontSize = Math.min(this.cameras.main.height * 0.25, 24);
this.message = this.add
.text(0, 0, renderedMessage, TEXT_STYLES.BASE)
.setOrigin(0.5, 0)
.setFontSize(fontSize)
.setPosition(
this.cameras.main.centerX,
this.rankings.getBounds().bottom + 32,
);
}
renderMessage() {
if (this.currentDeviceIndex < 0) return "You still haven't played!";
const place = this.leaderboardView[this.currentDeviceIndex].place;
if (place == 1) return "You're in first place!\nAmazing!";
if (place < 4) return "You're on the podium!\nExcellent!";
return `You're number ${place}\nGood job!`;
}
createBackBtn() {
const text = "Back to menu";
const fontSize = Math.min(this.cameras.main.height * 0.25, 32);
this.backBtn = this.add
.text(0, 0, text, TEXT_STYLES.BUTTON)
.setOrigin(0.5, 1)
.setFontSize(fontSize)
.setPosition(this.cameras.main.centerX, this.cameras.main.height * 0.95);
makeButtonHoverable(this.backBtn);
}
bindEvents() {
this.backBtn.on("pointerup", this.backToWelcome.bind(this));
}
backToWelcome() {
this.scene.start("welcome", { music: this.music });
}
}