merge: upstream

This commit is contained in:
Marie 2024-01-22 19:58:43 +01:00
commit fd69a2fbbd
No known key found for this signature in database
GPG Key ID: 56569BBE47D2C828
74 changed files with 659 additions and 745 deletions

View File

@ -161,11 +161,13 @@ describe('After user signed in', () => {
});
it('successfully loads', () => {
cy.get('[data-cy-user-setup-continue]').should('be.visible');
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 12000 }).should('be.visible');
});
it('account setup wizard', () => {
cy.get('[data-cy-user-setup-continue]').click();
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 12000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
@ -202,7 +204,8 @@ describe('After user setup', () => {
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 12000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});

View File

@ -1567,3 +1567,4 @@ _moderationLogTypes:
createInvitation: "ولِّد دعوة"
_reversi:
total: "المجموع"

View File

@ -1346,3 +1346,4 @@ _moderationLogTypes:
resetPassword: "পাসওয়ার্ড রিসেট করুন"
_reversi:
total: "মোট"

View File

@ -1276,3 +1276,4 @@ _moderationLogTypes:
resetPassword: "Restableix la contrasenya"
_reversi:
total: "Total"

View File

@ -2022,3 +2022,4 @@ _moderationLogTypes:
createInvitation: "Vygenerovat pozvánku"
_reversi:
total: "Celkem"

View File

@ -1,2 +1,3 @@
---
_lang_: "Dansk"

View File

@ -2247,3 +2247,4 @@ _externalResourceInstaller:
description: "Während der Installation des Farbschemas ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden."
_reversi:
total: "Gesamt"

View File

@ -398,3 +398,4 @@ _moderationLogTypes:
suspend: "Αποβολή"
_reversi:
total: "Σύνολο"

View File

@ -2561,3 +2561,4 @@ _dataSaver:
description: "If code highlighting notations are used in MFM, etc., they will not load until tapped. Syntax highlighting requires downloading the highlight definition files for each programming language. Therefore, disabling the automatic loading of these files is expected to reduce the amount of communication data."
_reversi:
total: "Total"

View File

@ -2429,3 +2429,4 @@ _dataSaver:
description: "Si se usa resaltado de código en MFM, etc., no se cargará hasta pulsar en ello. El resaltado de sintaxis requiere la descarga de archivos de definición para cada lenguaje de programación. Debido a esto, al deshabilitar la carga automática de estos archivos reducirás el consumo de datos."
_reversi:
total: "Total"

View File

@ -2085,3 +2085,4 @@ _dataSaver:
description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données."
_reversi:
total: "Total"

View File

@ -3,3 +3,4 @@ _lang_: "japanski"
ok: "OK"
gotIt: "Razumijem"
cancel: "otkazati"

View File

@ -16,3 +16,4 @@ _2fa:
renewTOTPCancel: "Sispann"
_widgets:
profile: "pwofil"

View File

@ -102,3 +102,4 @@ _deck:
_columns:
notifications: "Értesítések"
tl: "Idővonal"

View File

@ -2321,3 +2321,4 @@ _dataSaver:
description: "Jika notasi penyorotan kode digunakan di MFM, dll. Fungsi tersebut tidak akan dimuat apabila tidak diketuk. Penyorotan sintaks membutuhkan pengunduhan berkas definisi penyorotan untuk setiap bahasa pemrograman. Oleh sebab itu, menonaktifkan pemuatan otomatis dari berkas ini dilakukan untuk mengurangi jumlah komunikasi data."
_reversi:
total: "Jumlah"

View File

@ -2361,3 +2361,4 @@ _dataSaver:
description: "Impedire che il codice sorgente sia automaticamente evidenziato. Evidenziare il codice richiede il caricamento di un file per ogni linguaggio. Puoi evidenziare soltanto il codice che intendi leggere e ridurre il traffico inutilizzato."
_reversi:
total: "Totale"

View File

@ -2414,3 +2414,4 @@ _dataSaver:
description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。"
_reversi:
total: "合計"

View File

@ -1,3 +1,4 @@
---
_lang_: "la .lojban."
headlineMisskey: "lo se tcana noi jorne fi loi notci"

View File

@ -104,3 +104,4 @@ _deck:
_columns:
notifications: "Ilɣuyen"
list: "Tibdarin"

View File

@ -84,3 +84,4 @@ _deck:
notifications: "ಅಧಿಸೂಚನೆಗಳು"
tl: "ಸಮಯಸಾಲು"
mentions: "ಹೆಸರಿಸಿದ"

View File

@ -726,3 +726,4 @@ _moderationLogTypes:
resolveAbuseReport: "신고 해겔하기"
_reversi:
total: "합계"

View File

@ -2415,3 +2415,4 @@ _dataSaver:
description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않습니다. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와야 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있습니다."
_reversi:
total: "합계"

View File

@ -466,3 +466,4 @@ _webhookSettings:
name: "ຊື່"
_moderationLogTypes:
suspend: "ລະງັບ"

View File

@ -497,3 +497,4 @@ _webhookSettings:
_moderationLogTypes:
suspend: "Opschorten"
resetPassword: "Wachtwoord terugzetten"

View File

@ -720,3 +720,4 @@ _webhookSettings:
name: "Navn"
_moderationLogTypes:
suspend: "Suspender"

View File

@ -1399,3 +1399,4 @@ _moderationLogTypes:
resetPassword: "Zresetuj hasło"
_reversi:
total: "Łącznie"

View File

@ -1500,3 +1500,4 @@ _moderationLogTypes:
resetPassword: "Redefinir senha"
_reversi:
total: "Total"

View File

@ -729,3 +729,4 @@ _moderationLogTypes:
resetPassword: "Resetează parola"
_reversi:
total: "Total"

View File

@ -1972,3 +1972,4 @@ _moderationLogTypes:
resetPassword: "Сброс пароля:"
_reversi:
total: "Всего"

View File

@ -1 +1,2 @@
---

View File

@ -1447,3 +1447,4 @@ _moderationLogTypes:
resetPassword: "Resetovať heslo"
_reversi:
total: "Celkom"

View File

@ -576,3 +576,4 @@ _webhookSettings:
_moderationLogTypes:
suspend: "Suspendera"
resetPassword: "Återställ Lösenord"

View File

@ -2440,3 +2440,4 @@ _dataSaver:
description: "หากใช้สัญลักษณ์ไฮไลต์โค้ดใน MFM ฯลฯ สัญลักษณ์เหล่านั้นจะไม่โหลดจนกว่าจะแตะ การไฮไลต์ไวยากรณ์(syntax)จำเป็นต้องดาวน์โหลดไฟล์คำจำกัดความของไฮไลต์สำหรับแต่ละภาษา ดังนั้นการปิดใช้งานการโหลดไฟล์เหล่านี้โดยอัตโนมัติจึงคาดว่าจะช่วยลดปริมาณข้อมูลการสื่อสารได้"
_reversi:
total: "รวมทั้งหมด"

View File

@ -455,3 +455,4 @@ _deck:
_moderationLogTypes:
suspend: "askıya al"
resetPassword: "Şifre sıfırlama"

View File

@ -17,3 +17,4 @@ _2fa:
renewTOTPCancel: "ئۇنى توختىتىڭ"
_widgets:
profile: "profile"

View File

@ -1622,3 +1622,4 @@ _moderationLogTypes:
resetPassword: "Скинути пароль"
_reversi:
total: "Всього"

View File

@ -1090,3 +1090,4 @@ _moderationLogTypes:
resetPassword: "Parolni tiklash"
_reversi:
total: "Jami"

View File

@ -1852,3 +1852,4 @@ _moderationLogTypes:
resetPassword: "Đặt lại mật khẩu"
_reversi:
total: "Tổng cộng"

View File

@ -1200,6 +1200,8 @@ replaying: "重播中"
ranking: "排行榜"
lastNDays: "最近 {n} 天"
backToTitle: "返回标题"
hemisphere: "居住地区"
withSensitive: "显示包含敏感媒体的帖子"
enableHorizontalSwipe: "滑动切换标签页"
_bubbleGame:
howToPlay: "游戏说明"
@ -2427,9 +2429,14 @@ _dataSaver:
_code:
title: "代码高亮"
description: "如果使用了代码高亮标记,例如在 MFM 中,则在点击之前不会加载。 代码高亮要求加载每种高亮语言的定义文件,由于这些文件不再自动加载,因此有望减少数据传输量。"
_hemisphere:
N: "北半球"
S: "南半球"
caption: "在某些客户端设置中用来确定季节"
_reversi:
reversi: "黑白棋"
total: "总计"
_offlineScreen:
title: "离线——无法连接到服务器"
header: "无法连接到服务器"

View File

@ -1202,6 +1202,9 @@ replaying: "重播中"
ranking: "排行榜"
lastNDays: "過去 {n} 天"
backToTitle: "回到遊戲標題頁"
hemisphere: "您居住的地區"
withSensitive: "顯示包含敏感檔案的貼文"
userSaysSomethingSensitive: "包含 {name} 敏感檔案的貼文"
enableHorizontalSwipe: "滑動切換時間軸"
_bubbleGame:
howToPlay: "玩法說明"
@ -2438,5 +2441,48 @@ _dataSaver:
_code:
title: "程式碼突出顯示"
description: "如果使用了 MFM 的程式碼突顯標記,則在點擊之前不會載入。程式碼突顯要求加載每種程式語言的突顯定義檔案,但由於這些檔案不再自動載入,因此有望減少資料流量。"
_hemisphere:
N: "北半球"
S: "南半球"
caption: "在某些客戶端的設定中,用於判斷季節。"
_reversi:
reversi: "黑白棋"
gameSettings: "對弈設定"
chooseBoard: "選擇棋盤"
blackOrWhite: "先手/後手"
blackIs: "{name} 為黑棋(先攻)"
rules: "規則"
thisGameIsStartedSoon: "對弈即將開始"
waitingForOther: "等待對手準備就緒"
waitingForMe: "等待您準備就緒"
waitingBoth: "請準備"
ready: "準備就緒"
cancelReady: "重新準備"
opponentTurn: "對手的回合"
myTurn: "您的回合"
turnOf: "{name} 的回合"
pastTurnOf: "{name} 的回合"
surrender: "認輸"
surrendered: "對手認輸"
timeout: "時間到"
drawn: "平手"
won: "{name} 獲勝"
black: "黑"
white: "白"
total: "合計"
turnCount: "{count} 回合"
myGames: "我的對弈"
allGames: "所有對弈"
ended: ""
playing: "正在對弈"
isLlotheo: "子較少的一方為勝(顛倒規則)"
loopedMap: "循環棋盤"
canPutEverywhere: "隨意置放模式"
timeLimitForEachTurn: "每回合的時間限制"
freeMatch: "自由對戰"
lookingForPlayer: "正在搜尋對手"
gameCanceled: "對弈已被取消"
_offlineScreen:
title: "離線-無法連接伺服器"
header: "無法連接伺服器"

View File

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2024.1.0.beta2",
"version": "2024.2.0-beta1",
"codename": "shonk",
"repository": {
"type": "git",
@ -56,8 +56,8 @@
"typescript": "5.3.3"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"cross-env": "7.0.3",
"cypress": "13.6.3",
"eslint": "8.56.0",

View File

@ -8,7 +8,7 @@
},
"scripts": {
"start": "node ./built/boot/entry.js",
"start:test": "NODE_ENV=test node ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js",
@ -31,7 +31,7 @@
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"generate-api-json": "node ./generate_api_json.js"
"generate-api-json": "pnpm build && node ./generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",

View File

@ -55,23 +55,29 @@ export class AntennaService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'antennaCreated':
this.antennas.push({
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
break;
case 'antennaUpdated': {
const idx = this.antennas.findIndex(a => a.id === body.id);
if (idx >= 0) {
this.antennas[idx] = {
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
};
} else {
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
this.antennas.push({
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
}
}

View File

@ -51,7 +51,10 @@ export class MetaService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'metaUpdated': {
this.cache = body;
this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
proxyAccount: null, // joinなカラムは通常取ってこないので
};
break;
}
default:

View File

@ -12,18 +12,14 @@ import { IsNull } from 'typeorm';
import type {
MiReversiGame,
ReversiGamesRepository,
UsersRepository,
} from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { NotificationService } from '@/core/NotificationService.js';
import { Serialized } from '@/types.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
@ -58,7 +54,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
@bindThis
private async cacheGame(game: MiReversiGame) {
await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game));
await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 60, JSON.stringify(game));
}
@bindThis
@ -66,6 +62,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
await this.redisClient.del(`reversi:game:cache:${gameId}`);
}
@bindThis
private getBakeProps(game: MiReversiGame) {
return {
startedAt: game.startedAt,
endedAt: game.endedAt,
// ゲームの途中からユーザーが変わることは無いので
//user1Id: game.user1Id,
//user2Id: game.user2Id,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
black: game.black,
isStarted: game.isStarted,
isEnded: game.isEnded,
winnerId: game.winnerId,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
timeLimitForEachTurn: game.timeLimitForEachTurn,
logs: game.logs,
map: game.map,
bw: game.bw,
crc32: game.crc32,
} satisfies Partial<MiReversiGame>;
}
@bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) {
@ -81,23 +104,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if (invitations.includes(targetUser.id)) {
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: targetUser.id,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id });
this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed });
const game = await this.matched(targetUser.id, me.id);
return game;
} else {
@ -124,23 +131,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: invitorId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId });
this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed });
const game = await this.matched(invitorId, me.id);
return game;
}
@ -160,23 +151,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: matchedUserId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId });
this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed });
const game = await this.matched(matchedUserId, me.id);
return game;
} else {
@ -204,14 +179,10 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
let isBothReady = false;
if (game.user1Id === user.id) {
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
user1Ready: ready,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
const updatedGame = {
...game,
user1Ready: ready,
};
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
@ -221,14 +192,10 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if (ready && updatedGame.user2Ready) isBothReady = true;
} else if (game.user2Id === user.id) {
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
user2Ready: ready,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
const updatedGame = {
...game,
user2Ready: ready,
};
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
@ -253,6 +220,32 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
}
}
@bindThis
private async matched(parentId: MiUser['id'], childId: MiUser['id']): Promise<MiReversiGame> {
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: parentId,
user2Id: childId,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneOrFail({
where: { id: x.identifiers[0].id },
relations: ['user1', 'user2'],
}));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game);
this.globalEventService.publishReversiStream(parentId, 'matched', { game: packed });
return game;
}
@bindThis
private async startGame(game: MiReversiGame) {
let bw: number;
@ -262,63 +255,44 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
bw = parseInt(game.bw, 10);
}
function getRandomMap() {
const mapCount = Object.entries(Reversi.maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(Reversi.maps)[rnd].data;
}
const map = game.map != null ? game.map : getRandomMap();
const crc32 = CRC32.str(JSON.stringify(game.logs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
...this.getBakeProps(game),
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
map: game.map,
crc32,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
// キャッシュ効率化のためにユーザー情報は再利用
updatedGame.user1 = game.user1;
updatedGame.user2 = game.user2;
this.cacheGame(updatedGame);
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const engine = new Reversi.Game(map, {
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
const engine = new Reversi.Game(updatedGame.map, {
isLlotheo: updatedGame.isLlotheo,
canPutEverywhere: updatedGame.canPutEverywhere,
loopedBoard: updatedGame.loopedBoard,
});
if (engine.isEnded) {
let winner;
let winnerId;
if (engine.winner === true) {
winner = bw === 1 ? game.user1Id : game.user2Id;
winnerId = bw === 1 ? updatedGame.user1Id : updatedGame.user2Id;
} else if (engine.winner === false) {
winner = bw === 1 ? game.user2Id : game.user1Id;
winnerId = bw === 1 ? updatedGame.user2Id : updatedGame.user1Id;
} else {
winner = null;
winnerId = null;
}
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winner,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner,
game: await this.reversiGameEntityService.packDetail(game.id),
});
await this.endGame(updatedGame, winnerId, null);
return;
}
@ -327,7 +301,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, '');
this.globalEventService.publishReversiGameStream(game.id, 'started', {
game: await this.reversiGameEntityService.packDetail(game.id),
game: await this.reversiGameEntityService.packDetail(updatedGame),
});
}
@bindThis
private async endGame(game: MiReversiGame, winnerId: MiUser['id'] | null, reason: 'surrender' | 'timeout' | null) {
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
...this.getBakeProps(game),
isEnded: true,
endedAt: new Date(),
winnerId: winnerId,
surrenderedUserId: reason === 'surrender' ? (winnerId === game.user1Id ? game.user2Id : game.user1Id) : null,
timeoutUserId: reason === 'timeout' ? (winnerId === game.user1Id ? game.user2Id : game.user1Id) : null,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
// キャッシュ効率化のためにユーザー情報は再利用
updatedGame.user1 = game.user1;
updatedGame.user2 = game.user2;
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(updatedGame),
});
}
@ -354,14 +354,10 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
// TODO: より厳格なバリデーション
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
[key]: value,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
const updatedGame = {
...game,
[key]: value,
};
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
@ -397,17 +393,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
engine.putStone(pos);
let winner;
if (engine.isEnded) {
if (engine.winner === true) {
winner = game.black === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winner = game.black === 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
}
const logs = Reversi.Serializer.deserializeLogs(game.logs);
const log = {
@ -423,17 +408,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
crc32,
isEnded: engine.isEnded,
winnerId: winner,
logs: serializeLogs,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
const updatedGame = {
...game,
crc32,
logs: serializeLogs,
};
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'log', {
@ -442,10 +421,16 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
});
if (engine.isEnded) {
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner ?? null,
game: await this.reversiGameEntityService.packDetail(game.id),
});
let winnerId;
if (engine.winner === true) {
winnerId = game.black === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winnerId = game.black === 1 ? game.user2Id : game.user1Id;
} else {
winnerId = null;
}
await this.endGame(updatedGame, winnerId, null);
} else {
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`, updatedGame.timeLimitForEachTurn, '');
}
@ -460,23 +445,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winnerId,
surrenderedUserId: user.id,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(game.id),
});
await this.endGame(game, winnerId, 'surrender');
}
@bindThis
@ -500,23 +469,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if (timer === 0) {
const winnerId = engine.turn ? (game.black === 1 ? game.user2Id : game.user1Id) : (game.black === 1 ? game.user1Id : game.user2Id);
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winnerId,
timeoutUserId: engine.turn ? (game.black === 1 ? game.user1Id : game.user2Id) : (game.black === 1 ? game.user2Id : game.user1Id),
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(game.id),
});
await this.endGame(game, winnerId, 'timeout');
}
}
@ -539,14 +492,36 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
public async get(id: MiReversiGame['id']): Promise<MiReversiGame | null> {
const cached = await this.redisClient.get(`reversi:game:cache:${id}`);
if (cached != null) {
// TODO: この辺りのデシリアライズ処理をどこか別のサービスに切り出したい
const parsed = JSON.parse(cached) as Serialized<MiReversiGame>;
return {
...parsed,
startedAt: parsed.startedAt != null ? new Date(parsed.startedAt) : null,
endedAt: parsed.endedAt != null ? new Date(parsed.endedAt) : null,
user1: parsed.user1 != null ? {
...parsed.user1,
avatar: null,
banner: null,
updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null,
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
} : null,
user2: parsed.user2 != null ? {
...parsed.user2,
avatar: null,
banner: null,
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
} : null,
};
} else {
const game = await this.reversiGamesRepository.findOneBy({ id });
const game = await this.reversiGamesRepository.findOne({
where: { id },
relations: ['user1', 'user2'],
});
if (game == null) return null;
this.cacheGame(game);

View File

@ -181,9 +181,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
case 'userRoleAssigned': {
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
if (cached) {
cached.push({
cached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
user: null, // joinなカラムは通常取ってこないので
role: null, // joinなカラムは通常取ってこないので
});
}
break;

View File

@ -49,9 +49,10 @@ export class WebhookService implements OnApplicationShutdown {
switch (type) {
case 'webhookCreated':
if (body.active) {
this.webhooks.push({
this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
break;
@ -59,14 +60,16 @@ export class WebhookService implements OnApplicationShutdown {
if (body.active) {
const i = this.webhooks.findIndex(a => a.id === body.id);
if (i > -1) {
this.webhooks[i] = {
this.webhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
};
} else {
this.webhooks.push({
this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
} else {

View File

@ -9,7 +9,6 @@ import type { ReversiGamesRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiReversiGame } from '@/models/ReversiGame.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@ -29,10 +28,14 @@ export class ReversiGameEntityService {
@bindThis
public async packDetail(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameDetailed'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
const users = await Promise.all([
this.userEntityService.pack(game.user1 ?? game.user1Id),
this.userEntityService.pack(game.user2 ?? game.user2Id),
]);
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
@ -46,10 +49,10 @@ export class ReversiGameEntityService {
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
user1: users[0],
user2: users[1],
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
black: game.black,
@ -66,18 +69,21 @@ export class ReversiGameEntityService {
@bindThis
public packDetailMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packDetail(x, me)));
return Promise.all(xs.map(x => this.packDetail(x)));
}
@bindThis
public async packLite(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameLite'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
const users = await Promise.all([
this.userEntityService.pack(game.user1 ?? game.user1Id),
this.userEntityService.pack(game.user2 ?? game.user2Id),
]);
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
@ -85,16 +91,12 @@ export class ReversiGameEntityService {
endedAt: game.endedAt && game.endedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
user1: users[0],
user2: users[1],
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
black: game.black,
@ -109,9 +111,8 @@ export class ReversiGameEntityService {
@bindThis
public packLiteMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packLite(x, me)));
return Promise.all(xs.map(x => this.packLite(x)));
}
}

View File

@ -34,22 +34,6 @@ export const packedReversiGameLiteSchema = {
type: 'boolean',
optional: false, nullable: false,
},
form1: {
type: 'any',
optional: false, nullable: true,
},
form2: {
type: 'any',
optional: false, nullable: true,
},
user1Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user1Id: {
type: 'string',
optional: false, nullable: false,
@ -149,11 +133,11 @@ export const packedReversiGameDetailedSchema = {
optional: false, nullable: false,
},
form1: {
type: 'any',
type: 'object',
optional: false, nullable: true,
},
form2: {
type: 'any',
type: 'object',
optional: false, nullable: true,
},
user1Ready: {

View File

@ -43,7 +43,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
.andWhere('game.isStarted = TRUE');
.andWhere('game.isStarted = TRUE')
.innerJoinAndSelect('game.user1', 'user1')
.innerJoinAndSelect('game.user2', 'user2');
if (ps.my && me) {
query.andWhere(new Brackets(qb => {
@ -55,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const games = await query.take(ps.limit).getMany();
return await this.reversiGameEntityService.packLiteMany(games, me);
return await this.reversiGameEntityService.packLiteMany(games);
});
}
}

View File

@ -60,7 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (game == null) return;
return await this.reversiGameEntityService.packDetail(game, me);
return await this.reversiGameEntityService.packDetail(game);
});
}
}

View File

@ -48,7 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchGame);
}
return await this.reversiGameEntityService.packDetail(game, me);
return await this.reversiGameEntityService.packDetail(game);
});
}
}

View File

@ -42,7 +42,7 @@ class ReversiGameChannel extends Channel {
case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'cancel': this.cancelGame(); break;
case 'putStone': this.putStone(body.pos, body.id); break;
case 'checkState': this.checkState(body.crc32); break;
case 'resync': this.resync(body.crc32); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
}
}
@ -76,12 +76,10 @@ class ReversiGameChannel extends Channel {
}
@bindThis
private async checkState(crc32: string | number) {
if (crc32 != null) return;
private async resync(crc32: string | number) {
const game = await this.reversiService.checkCrc(this.gameId!, crc32);
if (game) {
this.send('rescue', game);
this.send('resynced', game);
}
}

View File

@ -31,12 +31,13 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, ReversiGamesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js';
@ -83,6 +84,9 @@ export class ClientServerService {
@Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository,
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private flashEntityService: FlashEntityService,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
@ -90,6 +94,7 @@ export class ClientServerService {
private galleryPostEntityService: GalleryPostEntityService,
private clipEntityService: ClipEntityService,
private channelEntityService: ChannelEntityService,
private reversiGameEntityService: ReversiGameEntityService,
private metaService: MetaService,
private urlPreviewService: UrlPreviewService,
private feedService: FeedService,
@ -704,6 +709,25 @@ export class ClientServerService {
return await renderBase(reply);
}
});
// Reversi game
fastify.get<{ Params: { game: string; } }>('/reversi/g/:game', async (request, reply) => {
const game = await this.reversiGamesRepository.findOneBy({
id: request.params.game,
});
if (game) {
const _game = await this.reversiGameEntityService.packDetail(game);
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('reversi-game', {
game: _game,
...this.generateCommonPugData(meta),
});
} else {
return await renderBase(reply);
}
});
//#endregion
fastify.get('/_info_card_', async (request, reply) => {

View File

@ -0,0 +1,20 @@
extends ./base
block vars
- const user1 = game.user1;
- const user2 = game.user2;
- const title = `${user1.username} vs ${user2.username}`;
- const url = `${config.url}/reversi/g/${game.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content='⚫⚪Misskey Reversi⚪⚫')
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content='⚫⚪Misskey Reversi⚪⚫')
meta(property='og:url' content= url)
meta(property='twitter:card' content='summary')

View File

@ -283,7 +283,11 @@ export type Serialized<T> = {
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
: T[K] extends (Record<string, any> | null)
? (Serialized<T[K]> | null)
: T[K] extends (Record<string, any> | undefined)
? (Serialized<T[K]> | undefined)
: T[K];
};
export type FilterUnionByProperty<

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -45,6 +45,7 @@
"crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
"defu": "^6.1.4",
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
@ -54,9 +55,9 @@
"json5": "2.2.3",
"katex": "0.16.9",
"matter-js": "0.19.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-bubble-game": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.6",
@ -112,7 +113,7 @@
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@vitest/coverage-v8": "1.2.1",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.15",
"acorn": "8.11.3",
"cross-env": "7.0.3",
@ -134,7 +135,7 @@
"storybook": "7.6.10",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "1.2.1",
"vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.4.0",
"vue-tsc": "1.8.27"

View File

@ -163,7 +163,7 @@ const $i = signinRequired();
const props = defineProps<{
game: Misskey.entities.ReversiGameDetailed;
connection: Misskey.ChannelConnection;
connection?: Misskey.ChannelConnection | null;
}>();
const showBoardLabels = ref<boolean>(false);
@ -240,10 +240,10 @@ watch(logPos, (v) => {
if (game.value.isStarted && !game.value.isEnded) {
useInterval(() => {
if (game.value.isEnded) return;
if (game.value.isEnded || props.connection == null) return;
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
if (_DEV_) console.log('crc32', crc32);
props.connection.send('checkState', {
props.connection.send('resync', {
crc32: crc32,
});
}, 10000, { immediate: false, afterMounted: true });
@ -267,7 +267,7 @@ function putStone(pos) {
});
const id = Math.random().toString(36).slice(2);
props.connection.send('putStone', {
props.connection!.send('putStone', {
pos: pos,
id,
});
@ -283,22 +283,24 @@ const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
const TIMER_INTERVAL_SEC = 3;
useInterval(() => {
if (myTurnTimerRmain.value > 0) {
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (opTurnTimerRmain.value > 0) {
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (iAmPlayer.value) {
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
props.connection.send('claimTimeIsUp', {});
if (!props.game.isEnded) {
useInterval(() => {
if (myTurnTimerRmain.value > 0) {
myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
if (opTurnTimerRmain.value > 0) {
opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
if (iAmPlayer.value) {
if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
props.connection!.send('claimTimeIsUp', {});
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
}
async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
game.value.logs = Reversi.Serializer.serializeLogs([
...Reversi.Serializer.deserializeLogs(game.value.logs),
log,
@ -309,17 +311,25 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
if (log.id == null || !appliedOps.includes(log.id)) {
switch (log.operation) {
case 'put': {
sound.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
if (log.player !== engine.value.turn) { // = desync
const _game = await misskeyApi('reversi/show-game', {
gameId: props.game.id,
});
restoreGame(_game);
return;
}
engine.value.putStone(log.pos);
triggerRef(engine);
myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
sound.playUrl('/client-assets/reversi/put.mp3', {
volume: 1,
playbackRate: 1,
});
checkEnd();
break;
}
@ -366,9 +376,7 @@ function checkEnd() {
}
}
function onStreamRescue(_game) {
console.log('rescue');
function restoreGame(_game) {
game.value = deepClone(_game);
engine.value = Reversi.Serializer.restoreGame({
@ -384,6 +392,12 @@ function onStreamRescue(_game) {
checkEnd();
}
function onStreamResynced(_game) {
console.log('resynced');
restoreGame(_game);
}
async function surrender() {
const { canceled } = await os.confirm({
type: 'warning',
@ -434,27 +448,35 @@ function share() {
}
onMounted(() => {
props.connection.on('log', onStreamLog);
props.connection.on('rescue', onStreamRescue);
props.connection.on('ended', onStreamEnded);
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded);
}
});
onActivated(() => {
props.connection.on('log', onStreamLog);
props.connection.on('rescue', onStreamRescue);
props.connection.on('ended', onStreamEnded);
if (props.connection != null) {
props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded);
}
});
onDeactivated(() => {
props.connection.off('log', onStreamLog);
props.connection.off('rescue', onStreamRescue);
props.connection.off('ended', onStreamEnded);
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded);
}
});
onUnmounted(() => {
props.connection.off('log', onStreamLog);
props.connection.off('rescue', onStreamRescue);
props.connection.off('ended', onStreamEnded);
if (props.connection != null) {
props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded);
}
});
</script>

View File

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="game == null || connection == null"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/>
<GameBoard v-else :game="game" :connection="connection"/>
</template>
@ -47,23 +47,25 @@ async function fetchGame() {
if (connection.value) {
connection.value.dispose();
}
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
connection.value.on('canceled', x => {
connection.value?.dispose();
if (!game.value.isEnded) {
connection.value = useStream().useChannel('reversiGame', {
gameId: game.value.id,
});
connection.value.on('started', x => {
game.value = x.game;
});
connection.value.on('canceled', x => {
connection.value?.dispose();
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
if (x.userId !== $i.id) {
os.alert({
type: 'warning',
text: i18n.ts._reversi.gameCanceled,
});
router.push('/reversi');
}
});
}
}
onMounted(() => {

View File

@ -73,9 +73,8 @@ const src = computed({
set: (x) => saveSrc(x),
});
const withRenotes = computed({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
get: () => (defaultStore.reactiveState.tl.value.filter?.withRenotes ?? saveTlFilter('withRenotes', true)),
set: (x) => saveTlFilter('withRenotes', x),
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x: boolean) => saveTlFilter('withRenotes', x),
});
const withReplies = computed({
get: () => {
@ -83,11 +82,10 @@ const withReplies = computed({
if (['local', 'social'].includes(src.value) && onlyFiles.value) {
return false;
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return defaultStore.reactiveState.tl.value.filter?.withReplies ?? saveTlFilter('withReplies', true);
return defaultStore.reactiveState.tl.value.filter.withReplies;
}
},
set: (x) => saveTlFilter('withReplies', x),
set: (x: boolean) => saveTlFilter('withReplies', x),
});
const withBots = computed({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -99,16 +97,14 @@ const onlyFiles = computed({
if (['local', 'social'].includes(src.value) && withReplies.value) {
return false;
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return defaultStore.reactiveState.tl.value.filter?.onlyFiles ?? saveTlFilter('onlyFiles', false);
return defaultStore.reactiveState.tl.value.filter.onlyFiles;
}
},
set: (x) => saveTlFilter('onlyFiles', x),
set: (x: boolean) => saveTlFilter('onlyFiles', x),
});
const withSensitive = computed({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
get: () => (defaultStore.reactiveState.tl.value.filter?.withSensitive ?? saveTlFilter('withSensitive', true)),
set: (x) => {
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
set: (x: boolean) => {
saveTlFilter('withSensitive', x);
//

View File

@ -7,6 +7,7 @@
import { onUnmounted, Ref, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel';
import { defu } from 'defu';
import { $i } from '@/account.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { get, set } from '@/scripts/idb-proxy.js';
@ -80,6 +81,18 @@ export class Storage<T extends StateDef> {
this.loaded = this.ready.then(() => this.load());
}
private isPureObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private mergeState<T>(value: T, def: T): T {
if (this.isPureObject(value) && this.isPureObject(def)) {
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def);
return defu(value, def) as T;
}
return value;
}
private async init(): Promise<void> {
await this.migrate();
@ -89,11 +102,11 @@ export class Storage<T extends StateDef> {
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) {
if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
this.reactiveState[k].value = this.state[k] = deviceState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default);
} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
this.reactiveState[k].value = this.state[k] = registryCache[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default);
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
this.reactiveState[k].value = this.state[k] = deviceAccountState[k];
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default);
} else {
this.reactiveState[k].value = this.state[k] = v.default;
if (_DEV_) console.log('Use default value', k, v.default);

View File

@ -24,11 +24,9 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/matter-js": "0.19.6",
"@types/node": "20.11.5",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0",
"nodemon": "3.0.2",
"typescript": "5.3.3"
@ -37,6 +35,8 @@
"built"
],
"dependencies": {
"@types/matter-js": "0.19.6",
"@types/seedrandom": "3.0.8",
"eventemitter3": "5.0.1",
"matter-js": "0.19.0",
"seedrandom": "3.0.5"

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021-2022 syuilo and other contributors
Copyright (c) 2021-2024 syuilo and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -81,7 +81,17 @@ module.exports = {
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
moduleNameMapper: {
// Do not resolve .wasm.js to .wasm by the rule below
'^(.+)\\.wasm\\.js$': '$1.wasm.js',
// SWC converts @/foo/bar.js to `../../src/foo/bar.js`, and then this rule
// converts it again to `../../src/foo/bar` which then can be resolved to
// `.ts` files.
// See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225
// TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can
// directly import `.ts` files without this hack.
'^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],

View File

@ -1,8 +1,9 @@
{
"type": "module",
"name": "misskey-js",
"version": "0.0.16",
"version": "2024.2.0-beta.3",
"description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts",
"exports": {
".": {
"import": "./built/esm/index.js",
@ -39,8 +40,8 @@
"@swc/jest": "0.2.31",
"@types/jest": "29.5.11",
"@types/node": "20.11.5",
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0",
"jest": "29.7.0",
"jest-fetch-mock": "3.0.3",
@ -52,7 +53,9 @@
"typescript": "5.3.3"
},
"files": [
"built"
"built",
"built/esm",
"built/dts"
],
"dependencies": {
"@swc/cli": "0.1.63",

View File

@ -1,6 +1,6 @@
/*
* version: 2023.12.2
* generatedAt: 2024-01-21T01:01:12.332Z
* version: 2024.2.0-beta.2
* generatedAt: 2024-01-22T07:11:08.412Z
*/
import type { SwitchCaseResponseType } from '../api.js';

View File

@ -1,6 +1,6 @@
/*
* version: 2023.12.2
* generatedAt: 2024-01-21T01:01:12.330Z
* version: 2024.2.0-beta.2
* generatedAt: 2024-01-22T07:11:08.410Z
*/
import type {

View File

@ -1,6 +1,6 @@
/*
* version: 2023.12.2
* generatedAt: 2024-01-21T01:01:12.328Z
* version: 2024.2.0-beta.2
* generatedAt: 2024-01-22T07:11:08.408Z
*/
import { operations } from './types.js';

View File

@ -1,6 +1,6 @@
/*
* version: 2023.12.2
* generatedAt: 2024-01-21T01:01:12.327Z
* version: 2024.2.0-beta.2
* generatedAt: 2024-01-22T07:11:08.408Z
*/
import { components } from './types.js';

View File

@ -2,8 +2,8 @@
/* eslint @typescript-eslint/no-explicit-any: 0 */
/*
* version: 2023.12.2
* generatedAt: 2024-01-21T01:01:12.246Z
* version: 2024.2.0-beta.2
* generatedAt: 2024-01-22T07:11:08.327Z
*/
/**
@ -4602,10 +4602,6 @@ export type components = {
endedAt: string | null;
isStarted: boolean;
isEnded: boolean;
form1: Record<string, never> | null;
form2: Record<string, never> | null;
user1Ready: boolean;
user2Ready: boolean;
/** Format: id */
user1Id: string;
/** Format: id */

View File

@ -25,8 +25,8 @@
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/node": "20.11.5",
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0",
"nodemon": "3.0.2",
"typescript": "5.3.3"

View File

@ -15,7 +15,7 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@typescript-eslint/parser": "6.19.0",
"@typescript-eslint/parser": "6.18.1",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.56.0",
"eslint-plugin-import": "2.29.1",

File diff suppressed because it is too large Load Diff