Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"printWidth": 120
}
48 changes: 48 additions & 0 deletions POSTGRES_PATCH.md
Original file line number Diff line number Diff line change
@@ -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 から復帰した旨のチャット通知が出ることを確認する。
118 changes: 116 additions & 2 deletions Room.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
var db = require('./db');

var Room = function () {};

Room.prototype.symbol = null;
Expand All @@ -9,13 +11,17 @@ 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;
this.userList = [];
this.owner = null;
this.isPlaying = false;
this.chatCount = {};
this.currentGameId = null;
this.restoredFromPostgres = false;

this.roomId = roomId;
this.redis = redis;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
};

Expand Down Expand Up @@ -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);
}
};
29 changes: 15 additions & 14 deletions app.js
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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);
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading