From b2f17edab3532dea8a4f1bbf0ad00a07bb976eae Mon Sep 17 00:00:00 2001 From: dokasen Date: Thu, 18 Jun 2026 08:25:41 +0900 Subject: [PATCH] Add PostgreSQL backup state, game logs, and tripcode users --- .prettierrc | 7 + POSTGRES_PATCH.md | 48 +++++ Room.js | 118 +++++++++++- app.js | 29 +-- battleraiso/BattleRaiso.js | 362 +++++++++++++++++++++---------------- battleraiso/Game.js | 56 +++--- cataso/Cataso.js | 19 +- db.js | 209 +++++++++++++++++++++ kcataso/Kcataso.js | 19 +- package-lock.json | 252 ++++++++++++++++++++++++++ package.json | 6 +- tripcode.js | 27 +++ 12 files changed, 941 insertions(+), 211 deletions(-) create mode 100644 .prettierrc create mode 100644 POSTGRES_PATCH.md create mode 100644 db.js create mode 100644 tripcode.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..141d99b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "printWidth": 120 +} diff --git a/POSTGRES_PATCH.md b/POSTGRES_PATCH.md new file mode 100644 index 0000000..db6bf16 --- /dev/null +++ b/POSTGRES_PATCH.md @@ -0,0 +1,48 @@ +# PostgreSQL persistence patch + +このパッチは Redis を主保存、PostgreSQL を副保存として使います。 +Redis から状態が取れないときは `room_states` から復帰し、チャットに復帰メッセージを流します。 + +## 必須環境変数 + +```env +DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=require +REDIS_TLS_URL=rediss://default:xxxxx@xxxxx:6379 +``` + +Render / Neon / Supabase などの TLS 必須 Postgres では `DATABASE_URL` をそのまま入れてください。 +ローカルで SSL 無しにする場合だけ以下を入れます。 + +```env +PGSSLMODE=disable +``` + +## 作成されるテーブル + +- `room_states`: 各卓の最新状態。Redis 消失時の復帰元。 +- `game_sessions`: ゲーム開始・終了日時、勝者、開始/終了時スナップショット。 +- `game_participants`: 参加者、座席、色、勝敗。 +- `app_users`: トリップ単位のユーザー管理。 +- `chat_logs`: チャットログ。 +- `user_game_stats`: 勝率集計ビュー。 + +## 2ちゃんねる互換トリップ + +`unix-crypt-td-js` を使って DES crypt(3) ベースのトリップを生成します。 +依存が入っていない場合は SHA-1 fallback でログイン自体は止めませんが、2ch互換ではありません。 + +## 確認SQL + +```sql +SELECT * FROM room_states ORDER BY updated_at DESC LIMIT 10; +SELECT * FROM game_sessions ORDER BY started_at DESC LIMIT 10; +SELECT * FROM user_game_stats ORDER BY games DESC, win_rate_percent DESC LIMIT 20; +``` + +## 復旧テスト + +1. 卓でゲームを開始する。 +2. `room_states` に該当 `room_id` が保存されていることを確認する。 +3. Redis 側の `room-N` を削除する、または Redis を再作成する。 +4. WebSocket サーバを再起動する。 +5. PostgreSQL から復帰した旨のチャット通知が出ることを確認する。 diff --git a/Room.js b/Room.js index ea7dc88..a67ae62 100644 --- a/Room.js +++ b/Room.js @@ -1,3 +1,5 @@ +var db = require('./db'); + var Room = function () {}; Room.prototype.symbol = null; @@ -9,6 +11,8 @@ Room.prototype.chatCount = {}; Room.prototype.redis = null; Room.prototype.roomId = null; Room.prototype.roomTrip = null; +Room.prototype.currentGameId = null; +Room.prototype.restoredFromPostgres = false; Room.prototype.initialize = function (symbol, roomId, redis) { this.symbol = symbol; @@ -16,6 +20,8 @@ Room.prototype.initialize = function (symbol, roomId, redis) { this.owner = null; this.isPlaying = false; this.chatCount = {}; + this.currentGameId = null; + this.restoredFromPostgres = false; this.roomId = roomId; this.redis = redis; @@ -118,13 +124,77 @@ Room.prototype.broadcast = function (message, resetWatchDog = true) { const msg = { isPlaying: this.isPlaying, game: JSON.parse(message), + savedAt: new Date().toISOString(), + currentGameId: this.currentGameId, }; this.saveGame(JSON.stringify(msg)); this._broadcast("I" + message); }; -Room.prototype.saveGame = function (game) { - this.redis.SET(`room-${this.roomId}`, game); +Room.prototype.saveGame = async function (game) { + var key = `room-${this.roomId}`; + try { + if (this.redis && this.redis.set) { + await this.redis.set(key, game); + } else if (this.redis && this.redis.SET) { + await this.redis.SET(key, game); + } + } catch (err) { + console.error(`[redis] failed to save ${key}`, err); + } + + try { + await db.saveRoomState(this.roomId, this.symbol || '?', game, 'broadcast'); + } catch (err) { + console.error(`[pg] failed to save ${key}`, err); + } +}; + +Room.prototype.restoreSavedGame = async function (applyState) { + var key = `room-${this.roomId}`; + var prev = null; + var source = null; + + try { + if (this.redis && this.redis.get) { + prev = await this.redis.get(key); + source = prev ? 'redis' : null; + } + } catch (err) { + console.error(`[redis] failed to load ${key}`, err); + } + + if (!prev) { + try { + var row = await db.loadRoomState(this.roomId); + if (row && row.payload) { + prev = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload); + source = 'postgres'; + } + } catch (err) { + console.error(`[pg] failed to load ${key}`, err); + } + } + + if (!prev) { return false; } + + try { + var parsed = JSON.parse(prev); + applyState(parsed); + if (source === 'postgres') { + this.restoredFromPostgres = true; + this.chat('?', 'orange', 'Redisの状態が見つからないため、PostgreSQLの保存状態から復帰しました。画面が安定するまで操作を控えてください。'); + this.saveGame(JSON.stringify(parsed)); + } + return source; + } catch (err) { + console.error(`[room] failed to restore ${key}`, err); + return false; + } +}; + +Room.prototype.findUserByUid = function (uid) { + return this.userList.find((u) => u.uid === uid); }; Room.prototype.chat = function (uid, color, message) { @@ -138,6 +208,8 @@ Room.prototype.chat = function (uid, color, message) { if (message.length > 140) { message = message.substr(0, 140) + "…"; } + var user = this.findUserByUid(uid); + db.logChat(this.roomId, uid, user ? user.trip : null, color, message); this._broadcast("H" + uid + " " + color + " " + message); }; @@ -262,3 +334,45 @@ Room.prototype.basicCommand = function (user, message) { }; module.exports = Room; + +Room.prototype.buildParticipants = function (playerList, colorNames) { + var participants = []; + for (var i = 0; i < playerList.length; i++) { + if (playerList[i] && playerList[i].uid) { + var user = this.findUserByUid(playerList[i].uid); + participants.push({ + seatIndex: i, + uid: playerList[i].uid, + trip: user ? user.trip : null, + colorName: colorNames ? colorNames[i] : null, + }); + } + } + return participants; +}; + +Room.prototype.beginGameLog = async function (snapshot, participants) { + try { + this.currentGameId = await db.startGameSession(this, JSON.parse(JSON.stringify(snapshot || {})), participants); + if (snapshot) { + this.saveGame(JSON.stringify({ isPlaying: this.isPlaying, game: JSON.parse(JSON.stringify(snapshot)), savedAt: new Date().toISOString(), currentGameId: this.currentGameId })); + } + } catch (err) { + console.error(`[pg] failed to start game log room-${this.roomId}`, err); + } +}; + +Room.prototype.finishGameLog = async function (winnerUid, reason, snapshot) { + try { + var user = this.findUserByUid(winnerUid); + await db.finishGameSession(this.currentGameId, { + winnerUid: winnerUid, + winnerTrip: user ? user.trip : null, + reason: reason, + snapshot: JSON.parse(JSON.stringify(snapshot || {})), + }); + this.currentGameId = null; + } catch (err) { + console.error(`[pg] failed to finish game log room-${this.roomId}`, err); + } +}; diff --git a/app.js b/app.js index 1ceee2b..f1873ae 100644 --- a/app.js +++ b/app.js @@ -1,13 +1,14 @@ var WebSocketServer = require("ws").Server; var http = require("http"); var server = http.createServer(); -var crypto = require("crypto"); var MersenneTwister = require("./MersenneTwister"); var Cataso = require("./cataso/Cataso"); var Kcataso = require("./kcataso/Kcataso"); var BattleRaiso = require("./battleraiso/BattleRaiso"); var Goipai = require("./goipai/Goipai"); var Blocas = require("./blocas/Blocas"); +var db = require("./db"); +var tripcode = require("./tripcode"); const redis = require("redis"); @@ -22,7 +23,8 @@ const client = redis.createClient({ rejectUnauthorized: false, }, }); -client.connect(); +client.connect().catch((err) => console.log("Redis connect error =>", err)); +db.init(); client.on("error", function (err) { console.log("Redis error =>", err); }); @@ -93,10 +95,11 @@ var roomList = [ new Blocas(60, client, true), // 60 ]; -var User = function (ws, uid, trip) { +var User = function (ws, uid, trip, name) { this.ws = ws; this.uid = uid; this.trip = trip; + this.name = name || uid; }; var splitSyntaxType1 = function (source) { @@ -128,17 +131,8 @@ var sendUserList = function (index, ws) { } catch (e) {} }; -var createTrip = function (source) { - while (source.length < 8) { - source += "H"; - } - - var cipher = crypto.createCipher("des-ecb", source.substr(0, 3)); - var crypted = cipher.update(source.substr(0, 8), "utf-8", "hex"); - crypted += cipher.final("hex"); +var createTrip = tripcode.createTrip; - return crypted.substr(0, 10); -}; var auth = function (index, trip) { if (!roomList[index].isAuth) return true; @@ -218,9 +212,12 @@ var login = function (index, ws, message) { sendUserList(index, ws); - var user = new User(ws, uid, trip); + var user = new User(ws, uid, trip, name); roomList[index].userList.push(user); + var ip = user.ws.upgradeReq && user.ws.upgradeReq.headers ? user.ws.upgradeReq.headers["x-forwarded-for"] : null; + db.upsertUser({ trip: trip || uid, uid: uid, displayName: name, ip: ip }); + if (user.trip !== "") { roomList[index]._broadcast("D" + user.uid + "%" + user.trip); } else { @@ -233,6 +230,10 @@ var login = function (index, ws, message) { if (roomList[index].owner !== null) { ws.send("F" + roomList[index].owner.uid); } + + if (roomList[index].restoredFromPostgres) { + ws.send("H? orange PostgreSQLの保存状態から復帰した卓です。状態に違和感があれば管理者に確認してください。"); + } } catch (e) {} } else { try { diff --git a/battleraiso/BattleRaiso.js b/battleraiso/BattleRaiso.js index 29d8155..332efc5 100644 --- a/battleraiso/BattleRaiso.js +++ b/battleraiso/BattleRaiso.js @@ -18,14 +18,14 @@ var BattleRaiso = function (roomId, redis, isAuth = false) { this.game = new Game(); this.mt = new MersenneTwister(); this.timer = null; - this.isAuth = isAuth + this.isAuth = isAuth; this.clockTimer = null; - this.redis.get(`room-${this.roomId}`).then(prev => { + this.redis.get(`room-${this.roomId}`).then((prev) => { if (prev) { this.isPlaying = JSON.parse(prev).isPlaying; Game.copy(this.game, JSON.parse(prev).game); - if (Phase.NONE !== JSON.parse(prev).game.phase) { + if (Phase.NONE !== JSON.parse(prev).game.phase) { this.startTimer(this.game); this.startBroadcastTimer(this.game); } @@ -33,13 +33,13 @@ var BattleRaiso = function (roomId, redis, isAuth = false) { Game.clear(this.game); } }); -} +}; BattleRaiso.prototype = new Room(); BattleRaiso.prototype.split = function (source) { return source.slice(1).split(' '); -} +}; BattleRaiso.prototype.reset = function () { this.isPlaying = false; @@ -49,7 +49,7 @@ BattleRaiso.prototype.reset = function () { Game.clear(this.game); this.broadcast(JSON.stringify(this.game)); -} +}; BattleRaiso.prototype.onCommand = function (user, message) { this.basicCommand(user, message); @@ -80,9 +80,28 @@ BattleRaiso.prototype.onCommand = function (user, message) { this.broadcast(JSON.stringify(this.game)); } + break; + case '/timeout': + if (this.isPlaying) { + this.chat('?', 'deeppink', 'プレイ中には変更できません。'); + } else if (this.owner !== user) { + this.chat('?', 'deeppink', '管理者のみ変更できます。'); + } else { + var t = parseInt(message[1]); + if (isNaN(t) || t >= 100) { + this.chat('?', 'deeppink', '0 ~ 99の数値を入力してください(0は無制限)'); + } else { + this.game.timeout = t; + var str = t === 0 ? '無制限' : t + '分'; + this.chat('?', 'deeppink', '持ち時間を' + str + 'に変更しました。'); + + this.broadcast(JSON.stringify(this.game)); + } + } + break; } -} +}; BattleRaiso.prototype.onChat = function (user, message) { var playerList = this.game.playerList; @@ -97,8 +116,8 @@ BattleRaiso.prototype.onChat = function (user, message) { } } - this.chat(user.uid, color, (message.split('<').join('<')).split('>').join('>')); -} + this.chat(user.uid, color, message.split('<').join('<').split('>').join('>')); +}; BattleRaiso.prototype.onMessage = function (uid, message) { if (message[0] === 'a') { @@ -145,10 +164,7 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var mt = that.mt; var playerList = game.playerList; - if ( - playerList[0].uid !== '' - && playerList[1].uid !== '' - ) { + if (playerList[0].uid !== '' && playerList[1].uid !== '') { Game.start(game, mt); var active = game.active; @@ -156,9 +172,9 @@ BattleRaiso.prototype.onMessage = function (uid, message) { that.startBroadcastTimer(game); that.chat( - '?' - , 'orange' - , '--「' + playerList[active].uid + '(' + COLOR_NAME[active] + ')」ターン--' + '?', + 'orange', + '--「' + playerList[active].uid + '(' + COLOR_NAME[active] + ')」ターン--' ); that.isPlaying = true; @@ -174,17 +190,17 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var game = that.game; if ( - game.phase === Phase.STARTUP - || game.phase === Phase.TROOP - || game.phase === Phase.ALEXANDER - || game.phase === Phase.DARIUS - || game.phase === Phase.COMPANION - || game.phase === Phase.SHIELD - || game.phase === Phase.SCOUT1 - || game.phase === Phase.REDEPLOY1 - || game.phase === Phase.DESERTER - || game.phase === Phase.TRAITOR1 - || game.phase === Phase.DRAW + game.phase === Phase.STARTUP || + game.phase === Phase.TROOP || + game.phase === Phase.ALEXANDER || + game.phase === Phase.DARIUS || + game.phase === Phase.COMPANION || + game.phase === Phase.SHIELD || + game.phase === Phase.SCOUT1 || + game.phase === Phase.REDEPLOY1 || + game.phase === Phase.DESERTER || + game.phase === Phase.TRAITOR1 || + game.phase === Phase.DRAW ) { var index = parseInt(that.split(message)[0]); @@ -196,20 +212,12 @@ BattleRaiso.prototype.onMessage = function (uid, message) { if (Game.isFinish(that.game)) { var playerList = game.playerList; - game.log.afterHand = game.playerList[game.active].hand.map(c => Game.getCardName(c)); + game.log.afterHand = game.playerList[game.active].hand.map((c) => Game.getCardName(c)); game.playLog.push(game.log); - that.chat( - '?' - , 'deeppink' - , '++勝利 おめでとう++' - ); + that.chat('?', 'deeppink', '++勝利 おめでとう++'); - that.chat( - '?' - , 'deeppink' - , playerList[active].uid + "(" + COLOR_NAME[active] + ")" - ); + that.chat('?', 'deeppink', playerList[active].uid + '(' + COLOR_NAME[active] + ')'); playerList[0].uid = playerList[1].uid = ''; @@ -230,18 +238,18 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var game = that.game; if ( - game.phase === Phase.STARTUP - || game.phase === Phase.TROOP - || game.phase === Phase.ALEXANDER - || game.phase === Phase.DARIUS - || game.phase === Phase.COMPANION - || game.phase === Phase.SHIELD - || game.phase === Phase.FOG - || game.phase === Phase.MUD - || game.phase === Phase.SCOUT1 - || game.phase === Phase.REDEPLOY1 - || game.phase === Phase.DESERTER - || game.phase === Phase.TRAITOR1 + game.phase === Phase.STARTUP || + game.phase === Phase.TROOP || + game.phase === Phase.ALEXANDER || + game.phase === Phase.DARIUS || + game.phase === Phase.COMPANION || + game.phase === Phase.SHIELD || + game.phase === Phase.FOG || + game.phase === Phase.MUD || + game.phase === Phase.SCOUT1 || + game.phase === Phase.REDEPLOY1 || + game.phase === Phase.DESERTER || + game.phase === Phase.TRAITOR1 ) { game.playing = parseInt(that.split(message)[0]); @@ -288,11 +296,11 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var game = that.game; if ( - game.phase === Phase.TROOP - || game.phase === Phase.ALEXANDER - || game.phase === Phase.DARIUS - || game.phase === Phase.COMPANION - || game.phase === Phase.SHIELD + game.phase === Phase.TROOP || + game.phase === Phase.ALEXANDER || + game.phase === Phase.DARIUS || + game.phase === Phase.COMPANION || + game.phase === Phase.SHIELD ) { var active = game.active; var activePlayer = game.playerList[active]; @@ -313,39 +321,22 @@ BattleRaiso.prototype.onMessage = function (uid, message) { before.y = index; if ((playingCard & 0xff00) !== 0x0600) { - that.chat( - '?' - , 'deeppink' - , '部隊カードをプレイしました。' - ); + that.chat('?', 'deeppink', '部隊カードをプレイしました。'); game.stock[((playingCard & 0xff00) >> 8) * 10 + (playingCard & 0x00ff)] = Index.NONE; } else { - switch (playingCard) - { + switch (playingCard) { case Tactics.ALEXANDER: case Tactics.DARIUS: - that.chat( - '?' - , 'deeppink' - , '隊長をプレイしました。' - ); + that.chat('?', 'deeppink', '隊長をプレイしました。'); activePlayer.leader++; break; case Tactics.COMPANION: - that.chat( - '?' - , 'deeppink' - , '援軍をプレイしました。' - ); + that.chat('?', 'deeppink', '援軍をプレイしました。'); break; case Tactics.SHIELD: - that.chat( - '?' - , 'deeppink' - , '盾をプレイしました。' - ); + that.chat('?', 'deeppink', '盾をプレイしました。'); break; } @@ -362,9 +353,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -397,9 +392,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -434,9 +433,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -451,13 +454,9 @@ BattleRaiso.prototype.onMessage = function (uid, message) { (function (that) { var game = that.game; - if ( - game.phase === Phase.SCOUT1 - || game.phase === Phase.SCOUT2 - ) { + if (game.phase === Phase.SCOUT1 || game.phase === Phase.SCOUT2) { var activePlayer = game.playerList[game.active]; - if (game.phase === Phase.SCOUT1) { that.chat('?', 'deeppink', '偵察をプレイしました。'); game.log.playingCard = Game.getCardName(activePlayer.hand[game.playing]); @@ -483,19 +482,20 @@ BattleRaiso.prototype.onMessage = function (uid, message) { activeHand.push(tacticsDeck.shift()); } - if ( - activeHand.length >= 9 - || (troopDeck.length === 0 && tacticsDeck.length === 0) - ) { + if (activeHand.length >= 9 || (troopDeck.length === 0 && tacticsDeck.length === 0)) { game.phase = Phase.SCOUT3; if (activeHand.length <= 7) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -512,11 +512,7 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var activeHand = game.playerList[game.active].hand; var index = parseInt(that.split(message)[0]); - if ( - game.phase === Phase.SCOUT3 - && activeHand.length > 7 - && activeHand.length > index - ) { + if (game.phase === Phase.SCOUT3 && activeHand.length > 7 && activeHand.length > index) { if ((activeHand[index] & 0xff00) === 0x0600) { that.chat('?', 'deeppink', '戦術カード デッキトップ。'); @@ -533,9 +529,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { game.before.idx = game.before.x = game.before.y = Index.NONE; Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; } else { @@ -561,7 +561,7 @@ BattleRaiso.prototype.onMessage = function (uid, message) { target.y = parseInt(param[0]); target.x = parseInt(param[1]); - that.chat('?', 'deeppink', (target.y + 1) + '列目から対象にしました。'); + that.chat('?', 'deeppink', target.y + 1 + '列目から対象にしました。'); game.phase = Phase.REDEPLOY2; game.sound = Sound.BUILD; @@ -583,10 +583,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var index = parseInt(that.split(message)[0]); game.log.targetCard = Game.getCardName(targetCard); - game.log.move = {before: target.y + 1, after: index + 1} + game.log.move = { + before: target.y + 1, + after: index + 1, + }; if (index === Index.NONE) { - that.chat('?', 'deeppink', (target.y + 1) + '列目から除外しました。'); + that.chat('?', 'deeppink', target.y + 1 + '列目から除外しました。'); activeTalon.push(targetCard); @@ -595,7 +598,7 @@ BattleRaiso.prototype.onMessage = function (uid, message) { } else { activeField[index].push(targetCard); - that.chat('?', 'deeppink', (index + 1) + '列目に移動しました。'); + that.chat('?', 'deeppink', index + 1 + '列目に移動しました。'); targetField.splice(target.x, 1); @@ -613,9 +616,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { } else { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; } @@ -637,12 +644,11 @@ BattleRaiso.prototype.onMessage = function (uid, message) { that.chat('?', 'deeppink', '脱走をプレイしました。'); game.log.playingCard = Game.getCardName(activePlayer.hand[game.playing]); game.log.targetCard = Game.getCardName(inactivePlayer.field[y][x]); - game.log.move = {before: y + 1, after: 0}; - + game.log.move = { before: y + 1, after: 0 }; activePlayer.count++; - that.chat('?', 'deeppink', (y + 1) + '列目から除外しました。'); + that.chat('?', 'deeppink', y + 1 + '列目から除外しました。'); inactivePlayer.talon.push(inactivePlayer.field[y][x]); inactivePlayer.field[y].splice(x, 1); @@ -660,9 +666,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -687,7 +697,7 @@ BattleRaiso.prototype.onMessage = function (uid, message) { target.y = parseInt(param[0]); target.x = parseInt(param[1]); - that.chat('?', 'deeppink', (target.y + 1) + '列目から対象にしました。'); + that.chat('?', 'deeppink', target.y + 1 + '列目から対象にしました。'); game.phase = Phase.TRAITOR2; game.sound = Sound.BUILD; @@ -708,9 +718,12 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var inactiveField = inactivePlayer.field[target.y]; game.log.targetCard = Game.getCardName(inactiveField[target.x]); - game.log.move = {before: target.y + 1, after: index + 1}; + game.log.move = { + before: target.y + 1, + after: index + 1, + }; - that.chat('?', 'deeppink', (index + 1) + '列目に移動しました。'); + that.chat('?', 'deeppink', index + 1 + '列目に移動しました。'); activeField.push(inactiveField[target.x]); @@ -731,9 +744,13 @@ BattleRaiso.prototype.onMessage = function (uid, message) { Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -758,14 +775,20 @@ BattleRaiso.prototype.onMessage = function (uid, message) { game.playerList[game.active].hand.push(game.tacticsDeck.shift()); } - game.log.draw = Game.getCardName(game.playerList[game.active].hand[game.playerList[game.active].hand.length - 1]); + game.log.draw = Game.getCardName( + game.playerList[game.active].hand[game.playerList[game.active].hand.length - 1] + ); Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -777,27 +800,31 @@ BattleRaiso.prototype.onMessage = function (uid, message) { var game = that.game; if ( - game.phase === Phase.STARTUP - || game.phase === Phase.ALEXANDER - || game.phase === Phase.DARIUS - || game.phase === Phase.COMPANION - || game.phase === Phase.SHIELD - || game.phase === Phase.FOG - || game.phase === Phase.MUD - || game.phase === Phase.SCOUT1 - || game.phase === Phase.REDEPLOY1 - || game.phase === Phase.DESERTER - || game.phase === Phase.TRAITOR1 - ) { + game.phase === Phase.STARTUP || + game.phase === Phase.ALEXANDER || + game.phase === Phase.DARIUS || + game.phase === Phase.COMPANION || + game.phase === Phase.SHIELD || + game.phase === Phase.FOG || + game.phase === Phase.MUD || + game.phase === Phase.SCOUT1 || + game.phase === Phase.REDEPLOY1 || + game.phase === Phase.DESERTER || + game.phase === Phase.TRAITOR1 + ) { that.chat('?', 'deeppink', 'パスしました。'); game.log.pass = true; Game.nextTurn(game); that.chat( - '?' - , 'orange' - , '--「' + game.playerList[game.active].uid + '(' + COLOR_NAME[game.active] + ')」ターン--' + '?', + 'orange', + '--「' + + game.playerList[game.active].uid + + '(' + + COLOR_NAME[game.active] + + ')」ターン--' ); game.sound = Sound.PASS; @@ -810,36 +837,61 @@ BattleRaiso.prototype.onMessage = function (uid, message) { this.broadcast(JSON.stringify(this.game)); this.game.sound = ''; } -} +}; BattleRaiso.prototype.stopTimer = function (game) { - if(this.timer) { + if (this.timer) { clearInterval(this.timer); } this.timer = null; -} +}; BattleRaiso.prototype.startTimer = function (game) { this.stopTimer(game); this.timer = setInterval(() => { game.playerList[game.active].time++; + if ( + this.timer && + game.timeout !== 0 && + game.playerList[game.active].time >= game.timeout * 60 && + this.isPlaying + ) { + game.state = State.READY; + this.isPlaying = false; + game.sound = Sound.ENDING; + game.phase = Phase.NONE; + game.playLog.push(game.log); + var playerList = game.playerList; + var looser = game.active; + var winner = game.active === 0 ? 1 : 0; + var lose_uid = playerList[looser].uid; + var win_uid = playerList[winner].uid; + + playerList[0].uid = playerList[1].uid = ''; + this.broadcast(JSON.stringify(game)); + this.game.sound = ''; + this.stopTimer(game); + this.stopBroadcastTimer(game); + + this.chat('?', 'orange', '--「' + lose_uid + '(' + COLOR_NAME[looser] + ')」時間切れ--'); + this.chat('?', 'deeppink', '++勝利 おめでとう++'); + this.chat('?', 'deeppink', win_uid + '(' + COLOR_NAME[winner] + ')'); + } }, 1000); -} +}; BattleRaiso.prototype.stopBroadcastTimer = function (game) { - if(this.clockTimer) { + if (this.clockTimer) { clearInterval(this.clockTimer); } this.clockTimer = null; -} +}; BattleRaiso.prototype.startBroadcastTimer = function (game) { this.stopBroadcastTimer(game); this.clockTimer = setInterval(() => { - this.broadcast(JSON.stringify(game), false) + this.broadcast(JSON.stringify(game), false); }, 1000); - -} - +}; -module.exports = BattleRaiso; \ No newline at end of file +module.exports = BattleRaiso; diff --git a/battleraiso/Game.js b/battleraiso/Game.js index 2656cba..4b7e4b4 100644 --- a/battleraiso/Game.js +++ b/battleraiso/Game.js @@ -7,7 +7,7 @@ var Phase = Const.Phase; var Mode = Const.Mode; var CARD_COLOR = Const.CARD_COLOR; -var Game = function () { } +var Game = function () {}; Game.clear = function (game) { game.setup = Mode.ADVANCE; @@ -27,12 +27,13 @@ Game.clear = function (game) { game.playLog = []; game.log = {}; game.turn = 1; + game.timeout = 0; - var playerList = game.playerList = [new Player(), new Player()]; + var playerList = (game.playerList = [new Player(), new Player()]); Player.clear(playerList[0]); Player.clear(playerList[1]); -} +}; Game.copy = function (game, prev) { game.setup = prev.setup; game.state = prev.state; @@ -47,16 +48,21 @@ Game.copy = function (game, prev) { game.size = prev.size || []; game.weather = prev.weather || []; game.stock = prev.stock || []; - game.before = prev.before || { idx: Index.NONE, x: Index.NONE, y: Index.NONE }; + game.before = prev.before || { + idx: Index.NONE, + x: Index.NONE, + y: Index.NONE, + }; game.playLog = prev.playLog || []; game.log = prev.log || {}; game.turn = prev.turn || 1; + game.timeout = prev.timeout || 0; - var playerList = game.playerList = [new Player(), new Player()]; + var playerList = (game.playerList = [new Player(), new Player()]); Player.copy(playerList[0], prev.playerList[0]); Player.copy(playerList[1], prev.playerList[1]); -} +}; Game.start = function (game, mt) { game.state = State.PLAYING; @@ -143,11 +149,11 @@ Game.start = function (game, mt) { game.log = { uid: playerList[0].uid, turn: game.turn, - beforeHand: playerList[0].hand.map(c => Game.getCardName(c)), + beforeHand: playerList[0].hand.map((c) => Game.getCardName(c)), afterHand: [], flag: [], - } -} + }; +}; Game.discard = function (game) { var playerList = game.playerList; @@ -160,38 +166,40 @@ Game.discard = function (game) { hand.splice(playing, 1); game.playing = Index.NONE; -} +}; Game.nextTurn = function (game) { - game.log.afterHand = game.playerList[game.active].hand.map(c => Game.getCardName(c)); + game.log.afterHand = game.playerList[game.active].hand.map((c) => Game.getCardName(c)); var dt = new Date(); var y = dt.getFullYear(); - var m = String(dt.getMonth()+1).padStart(2, '0'); + var m = String(dt.getMonth() + 1).padStart(2, '0'); var d = String(dt.getDate()).padStart(2, '0'); var result = y + m + d; game.log.meta = { date: result, player1: game.playerList[0].uid, - player2: game.playerList[1].uid - } + player2: game.playerList[1].uid, + }; game.playLog.push(game.log); game.active = game.active === 0 ? 1 : 0; game.log = { turn: ++game.turn, uid: game.playerList[game.active].uid, - beforeHand: game.playerList[game.active].hand.map(c => Game.getCardName(c)), + beforeHand: game.playerList[game.active].hand.map((c) => Game.getCardName(c)), afterHand: [], - flag: [] + flag: [], }; game.phase = Phase.STARTUP; game.playing = Index.NONE; -} +}; Game.isFinish = function (game) { var active = game.active; var flagList = game.flagList; - var i, sequence = 0, count = 0; + var i, + sequence = 0, + count = 0; var len1 = flagList.length; for (i = 0; i < len1; i++) { if (flagList[i] === active) { @@ -206,20 +214,20 @@ Game.isFinish = function (game) { } return false; -} +}; Game.getCardName = function (card) { var color = (card & 0xff00) >> 8; var number = (card & 0x00ff) + 1; - if(color < 6) { + if (color < 6) { return CARD_COLOR[color] + number; } - switch(card) { + switch (card) { case Tactics.ALEXANDER: - case Tactics.DARIUS : + case Tactics.DARIUS: return '隊長'; - case Tactics.COMPANION : + case Tactics.COMPANION: return '援軍'; case Tactics.SHIELD: return '盾'; @@ -236,6 +244,6 @@ Game.getCardName = function (card) { case Tactics.TRAITOR: return '裏切り'; } -} +}; module.exports = Game; diff --git a/cataso/Cataso.js b/cataso/Cataso.js index b3d57e6..1e0e382 100644 --- a/cataso/Cataso.js +++ b/cataso/Cataso.js @@ -25,13 +25,15 @@ var Cataso = function (roomId, redis, isAuth = false) { this.isAuth = isAuth; this.mt = new MersenneTwister(); - this.redis.get(`room-${this.roomId}`).then(prev => { - if (prev) { - this.isPlaying = JSON.parse(prev).isPlaying; - Game.copy(this.game, JSON.parse(prev).game); - Dice.clear(this.dice, this.mt) - } else { - Game.clear(this.game); + this.restoreSavedGame((prev) => { + this.isPlaying = !!prev.isPlaying; + this.currentGameId = prev.currentGameId || null; + Game.copy(this.game, prev.game); + Dice.clear(this.dice, this.mt); + }).then((source) => { + if (!source) { Game.clear(this.game); } + if (source === 'postgres') { + console.log(`[room-${this.roomId}] restored from PostgreSQL fallback`); } }); @@ -44,6 +46,7 @@ Cataso.prototype.split = function (source) { } Cataso.prototype.reset = function () { + if (this.isPlaying) { this.finishGameLog(null, 'reset', this.game); } this.isPlaying = false; Game.clear(this.game); @@ -171,6 +174,7 @@ Cataso.prototype.onMessage = function (uid, message) { ) { Game.start(game, mt); Dice.clear(that.dice, mt); + that.beginGameLog(game, that.buildParticipants(playerList, COLOR_NAME)); var active = game.active; @@ -870,6 +874,7 @@ Cataso.prototype.onMessage = function (uid, message) { var active = game.active; var playerList = game.playerList; var activePlayer = playerList[active]; + that.finishGameLog(activePlayer.uid, 'victory', game); that.chat( '?' diff --git a/db.js b/db.js new file mode 100644 index 0000000..253cd6f --- /dev/null +++ b/db.js @@ -0,0 +1,209 @@ +const { Pool } = require('pg'); + +const connectionString = process.env.DATABASE_URL || process.env.POSTGRES_URL || ''; +const enabled = !!connectionString; + +const pool = enabled + ? new Pool({ + connectionString, + ssl: process.env.PGSSLMODE === 'disable' ? false : { rejectUnauthorized: false }, + max: Number(process.env.PG_POOL_MAX || 4), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }) + : null; + +let initPromise = null; +let ready = false; +let lastError = null; + +async function init() { + if (!enabled) return false; + if (initPromise) return initPromise; + initPromise = (async () => { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS app_users ( + trip text PRIMARY KEY, + display_name text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + login_count integer NOT NULL DEFAULT 0, + ip_hash text + ); + + CREATE TABLE IF NOT EXISTS room_states ( + room_id integer PRIMARY KEY, + symbol text NOT NULL, + payload jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + source text NOT NULL DEFAULT 'server' + ); + + CREATE TABLE IF NOT EXISTS game_sessions ( + id bigserial PRIMARY KEY, + room_id integer NOT NULL, + symbol text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + winner_uid text, + winner_trip text, + end_reason text, + start_payload jsonb, + end_payload jsonb + ); + + CREATE INDEX IF NOT EXISTS game_sessions_room_started_idx + ON game_sessions(room_id, started_at DESC); + + CREATE TABLE IF NOT EXISTS game_participants ( + game_id bigint NOT NULL REFERENCES game_sessions(id) ON DELETE CASCADE, + seat_index integer NOT NULL, + uid text NOT NULL, + trip text, + color_name text, + is_winner boolean NOT NULL DEFAULT false, + PRIMARY KEY(game_id, seat_index) + ); + + CREATE INDEX IF NOT EXISTS game_participants_trip_idx + ON game_participants(trip); + + CREATE TABLE IF NOT EXISTS chat_logs ( + id bigserial PRIMARY KEY, + room_id integer NOT NULL, + uid text NOT NULL, + trip text, + color text, + message text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE OR REPLACE VIEW user_game_stats AS + SELECT + p.trip, + max(p.uid) AS latest_uid, + count(*)::integer AS games, + sum(CASE WHEN p.is_winner THEN 1 ELSE 0 END)::integer AS wins, + CASE WHEN count(*) = 0 THEN 0 ELSE round((sum(CASE WHEN p.is_winner THEN 1 ELSE 0 END)::numeric / count(*)) * 100, 2) END AS win_rate_percent, + min(s.started_at) AS first_game_at, + max(s.ended_at) AS last_game_at + FROM game_participants p + JOIN game_sessions s ON s.id = p.game_id + WHERE s.ended_at IS NOT NULL + GROUP BY p.trip; + `); + ready = true; + console.log('[pg] ready'); + return true; + } catch (err) { + ready = false; + lastError = err; + console.error('[pg] init failed', err); + return false; + } + })(); + return initPromise; +} + +async function query(sql, params) { + if (!enabled) return null; + await init(); + if (!ready) return null; + try { + return await pool.query(sql, params); + } catch (err) { + lastError = err; + console.error('[pg] query failed', err); + return null; + } +} + +async function upsertUser({ trip, uid, displayName, ip }) { + if (!trip) return; + const ipHash = ip ? require('crypto').createHash('sha256').update(String(ip)).digest('hex') : null; + await query( + `INSERT INTO app_users (trip, display_name, login_count, ip_hash) + VALUES ($1, $2, 1, $3) + ON CONFLICT (trip) DO UPDATE SET + display_name = EXCLUDED.display_name, + last_seen_at = now(), + login_count = app_users.login_count + 1, + ip_hash = COALESCE(EXCLUDED.ip_hash, app_users.ip_hash)`, + [trip, displayName || uid || trip, ipHash] + ); +} + +async function saveRoomState(roomId, symbol, payload, source = 'server') { + await query( + `INSERT INTO room_states (room_id, symbol, payload, updated_at, source) + VALUES ($1, $2, $3::jsonb, now(), $4) + ON CONFLICT (room_id) DO UPDATE SET + symbol = EXCLUDED.symbol, + payload = EXCLUDED.payload, + updated_at = now(), + source = EXCLUDED.source`, + [roomId, symbol, payload, source] + ); +} + +async function loadRoomState(roomId) { + const res = await query('SELECT payload, updated_at FROM room_states WHERE room_id = $1', [roomId]); + if (!res || res.rows.length === 0) return null; + return res.rows[0]; +} + +async function startGameSession(room, snapshot, participants) { + const res = await query( + `INSERT INTO game_sessions (room_id, symbol, start_payload) + VALUES ($1, $2, $3::jsonb) RETURNING id`, + [room.roomId, room.symbol, JSON.stringify(snapshot || {})] + ); + if (!res || res.rows.length === 0) return null; + const gameId = res.rows[0].id; + for (const p of participants || []) { + await query( + `INSERT INTO game_participants (game_id, seat_index, uid, trip, color_name) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (game_id, seat_index) DO UPDATE SET uid = EXCLUDED.uid, trip = EXCLUDED.trip, color_name = EXCLUDED.color_name`, + [gameId, p.seatIndex, p.uid, p.trip || null, p.colorName || null] + ); + } + return gameId; +} + +async function finishGameSession(gameId, { winnerUid, winnerTrip, reason, snapshot }) { + if (!gameId) return; + await query( + `UPDATE game_sessions SET ended_at = now(), winner_uid = $2, winner_trip = $3, end_reason = $4, end_payload = $5::jsonb + WHERE id = $1 AND ended_at IS NULL`, + [gameId, winnerUid || null, winnerTrip || null, reason || 'finished', JSON.stringify(snapshot || {})] + ); + if (winnerUid || winnerTrip) { + await query( + `UPDATE game_participants SET is_winner = true + WHERE game_id = $1 AND (($2::text IS NOT NULL AND uid = $2) OR ($3::text IS NOT NULL AND trip = $3))`, + [gameId, winnerUid || null, winnerTrip || null] + ); + } +} + +async function logChat(roomId, uid, trip, color, message) { + await query( + `INSERT INTO chat_logs (room_id, uid, trip, color, message) VALUES ($1, $2, $3, $4, $5)`, + [roomId, uid || '?', trip || null, color || null, String(message || '').slice(0, 500)] + ); +} + +module.exports = { + enabled, + init, + query, + upsertUser, + saveRoomState, + loadRoomState, + startGameSession, + finishGameSession, + logChat, + get lastError() { return lastError; }, +}; diff --git a/kcataso/Kcataso.js b/kcataso/Kcataso.js index 2ea4628..76daa17 100644 --- a/kcataso/Kcataso.js +++ b/kcataso/Kcataso.js @@ -30,13 +30,15 @@ var Kcataso = function (roomId, redis, isAuth = false) { this.isAuth = isAuth; this.mt = new MersenneTwister(); - this.redis.get(`room-${this.roomId}`).then(prev => { - if (prev) { - this.isPlaying = JSON.parse(prev).isPlaying; - Game.copy(this.game, JSON.parse(prev).game); - Dice.clear(this.dice, this.mt) - } else { - Game.clear(this.game); + this.restoreSavedGame((prev) => { + this.isPlaying = !!prev.isPlaying; + this.currentGameId = prev.currentGameId || null; + Game.copy(this.game, prev.game); + Dice.clear(this.dice, this.mt); + }).then((source) => { + if (!source) { Game.clear(this.game); } + if (source === 'postgres') { + console.log(`[room-${this.roomId}] restored from PostgreSQL fallback`); } }); } @@ -48,6 +50,7 @@ Kcataso.prototype.split = function (source) { } Kcataso.prototype.reset = function () { + if (this.isPlaying) { this.finishGameLog(null, 'reset', this.game); } this.isPlaying = false; Game.clear(this.game); @@ -175,6 +178,7 @@ Kcataso.prototype.onMessage = function (uid, message) { ) { Game.start(game, mt); Dice.clear(that.dice, mt); + that.beginGameLog(game, that.buildParticipants(playerList, COLOR_NAME)); var active = game.active; @@ -857,6 +861,7 @@ Kcataso.prototype.onMessage = function (uid, message) { var active = game.active; var playerList = game.playerList; var activePlayer = playerList[active]; + that.finishGameLog(activePlayer.uid, 'victory', game); that.chat( '?' diff --git a/package-lock.json b/package-lock.json index 28fb327..ec92093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.1", "dependencies": { "express": "^4.4.5", + "pg": "^8.11.5", "redis": "^4.0.4", + "unix-crypt-td-js": "^1.1.4", "ws": "0.4.x" }, "devDependencies": { @@ -697,6 +699,95 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -709,6 +800,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -905,6 +1035,15 @@ "semver": "bin/semver.js" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -983,6 +1122,12 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/unix-crypt-td-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==", + "license": "BSD-3-Clause" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1025,6 +1170,15 @@ "node": ">=0.4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -1542,12 +1696,95 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "requires": { + "pg-cloudflare": "^1.4.0", + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + } + }, + "pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "optional": true + }, + "pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==" + }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "requires": {} + }, + "pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==" + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "requires": { + "split2": "^4.1.0" + } + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" + }, + "postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "requires": { + "xtend": "^4.0.0" + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1695,6 +1932,11 @@ } } }, + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -1752,6 +1994,11 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "unix-crypt-td-js": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1778,6 +2025,11 @@ "tinycolor": "0.x" } }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index a0ff3af..6bcc293 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "dependencies": { "express": "^4.4.5", "redis": "^4.0.4", - "ws": "0.4.x" + "ws": "0.4.x", + "pg": "^8.11.5", + "unix-crypt-td-js": "^1.1.4" }, "engines": { "node": "16.4.2" @@ -20,4 +22,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} +} \ No newline at end of file diff --git a/tripcode.js b/tripcode.js new file mode 100644 index 0000000..6851ee9 --- /dev/null +++ b/tripcode.js @@ -0,0 +1,27 @@ +let crypt = null; +try { + crypt = require('unix-crypt-td-js'); +} catch (e) { + crypt = null; +} + +function sanitizeSalt(s) { + return String(s || '') + .replace(/[\x00-\x20:;<=>?@[\\\]^_`]/g, '.') + .replace(/[^.\/0-9A-Za-z]/g, '.'); +} + +function createTrip(source) { + source = String(source || ''); + const salt = sanitizeSalt((source + 'H.').slice(1, 3)); + if (crypt) { + const result = typeof crypt === 'function' ? crypt(source, salt) : crypt.crypt(source, salt); + return String(result).slice(-10); + } + // Fallback:旧実装互換ではないが、依存が無い環境でもログインを止めない。 + // 2ch互換にするには package.json の unix-crypt-td-js を install すること。 + const crypto = require('crypto'); + return crypto.createHash('sha1').update(source).digest('base64').replace(/[+=/]/g, '').slice(0, 10); +} + +module.exports = { createTrip };