Track traffic by game month

This commit is contained in:
2026-07-27 11:32:48 +00:00
parent 9f402a3d67
commit 2b15410c5c
9 changed files with 595 additions and 146 deletions
+34
View File
@@ -97,6 +97,8 @@ model WorldState {
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
trafficPeriods TrafficPeriod[]
@@map("world_state")
}
@@ -204,6 +206,38 @@ model GeneralAccessLog {
@@map("general_access_log")
}
model TrafficPeriod {
id Int @id @default(autoincrement())
worldStateId Int @map("world_state_id")
year Int
month Int
startedAt DateTime @map("started_at")
lastRefresh DateTime @map("last_refresh")
refresh Int @default(0)
online Int @default(0)
worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade)
generals TrafficPeriodGeneral[]
@@unique([worldStateId, year, month])
@@index([worldStateId, startedAt])
@@map("traffic_period")
}
model TrafficPeriodGeneral {
periodId Int @map("period_id")
generalId Int @map("general_id")
userId String? @map("user_id")
refresh Int @default(0)
lastRefresh DateTime @map("last_refresh")
period TrafficPeriod @relation(fields: [periodId], references: [id], onDelete: Cascade)
@@id([periodId, generalId])
@@index([periodId, refresh, generalId])
@@map("traffic_period_general")
}
model MessageReadState {
generalId Int @id @map("general_id")
latestPrivateMessage Int @default(0) @map("latest_private_message")
@@ -0,0 +1,111 @@
CREATE TABLE "traffic_period" (
"id" SERIAL PRIMARY KEY,
"world_state_id" INTEGER NOT NULL,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"started_at" TIMESTAMP(3) NOT NULL,
"last_refresh" TIMESTAMP(3) NOT NULL,
"refresh" INTEGER NOT NULL DEFAULT 0,
"online" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "traffic_period_world_state_id_fkey"
FOREIGN KEY ("world_state_id") REFERENCES "world_state"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "traffic_period_month_check" CHECK ("month" BETWEEN 1 AND 12),
CONSTRAINT "traffic_period_refresh_check" CHECK ("refresh" >= 0),
CONSTRAINT "traffic_period_online_check" CHECK ("online" >= 0)
);
CREATE UNIQUE INDEX "traffic_period_world_state_year_month_key"
ON "traffic_period"("world_state_id", "year", "month");
CREATE INDEX "traffic_period_world_state_started_at_idx"
ON "traffic_period"("world_state_id", "started_at");
CREATE TABLE "traffic_period_general" (
"period_id" INTEGER NOT NULL,
"general_id" INTEGER NOT NULL,
"user_id" TEXT,
"refresh" INTEGER NOT NULL DEFAULT 0,
"last_refresh" TIMESTAMP(3) NOT NULL,
CONSTRAINT "traffic_period_general_pkey" PRIMARY KEY ("period_id", "general_id"),
CONSTRAINT "traffic_period_general_period_id_fkey"
FOREIGN KEY ("period_id") REFERENCES "traffic_period"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "traffic_period_general_refresh_check" CHECK ("refresh" >= 0)
);
CREATE INDEX "traffic_period_general_period_refresh_general_idx"
ON "traffic_period_general"("period_id", "refresh", "general_id");
-- Preserve legacy completed-period snapshots. Invalid or missing legacy dates use
-- the world row timestamp so one malformed JSON item cannot block deployment.
WITH legacy_rows AS (
SELECT
world."id" AS "world_state_id",
item,
ordinality
FROM "world_state" AS world
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(world."meta"->'recentTraffic') = 'array'
THEN world."meta"->'recentTraffic'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS legacy(item, ordinality)
),
normalized AS (
SELECT
legacy_rows."world_state_id",
CASE
WHEN pg_input_is_valid(legacy_rows.item->>'year', 'integer')
THEN (legacy_rows.item->>'year')::INTEGER
ELSE NULL
END AS "year",
CASE
WHEN pg_input_is_valid(legacy_rows.item->>'month', 'integer')
THEN (legacy_rows.item->>'month')::INTEGER
ELSE NULL
END AS "month",
CASE
WHEN pg_input_is_valid(legacy_rows.item->>'refresh', 'integer')
THEN (legacy_rows.item->>'refresh')::INTEGER
ELSE 0
END AS "refresh",
CASE
WHEN pg_input_is_valid(legacy_rows.item->>'online', 'integer')
THEN (legacy_rows.item->>'online')::INTEGER
ELSE 0
END AS "online",
CASE
WHEN pg_input_is_valid(
legacy_rows.item->>'date',
'timestamp without time zone'
)
THEN (legacy_rows.item->>'date')::TIMESTAMP
ELSE world."updated_at"
END AS "observed_at",
legacy_rows.ordinality
FROM legacy_rows
JOIN "world_state" AS world ON world."id" = legacy_rows."world_state_id"
)
INSERT INTO "traffic_period" (
"world_state_id",
"year",
"month",
"started_at",
"last_refresh",
"refresh",
"online"
)
SELECT DISTINCT ON ("world_state_id", "year", "month")
"world_state_id",
"year",
"month",
"observed_at",
"observed_at",
"refresh",
"online"
FROM normalized
WHERE "year" IS NOT NULL
AND "month" BETWEEN 1 AND 12
ORDER BY "world_state_id", "year", "month", ordinality DESC
ON CONFLICT ("world_state_id", "year", "month") DO NOTHING;
@@ -33,6 +33,8 @@ pnpm --filter @sammo-ts/infra prisma:migrate:status:game
- `nation.chief_general_id` 존재
- `city.trade` nullable, `city.trust` REAL
- `auction_bid.meta` JSONB, NOT NULL
- `traffic_period`, `traffic_period_general` 존재 및
`(world_state_id, year, month)`/`(period_id, general_id)` unique key
검증 뒤에는 이름을 확인한 임시 database와 role만 제거한다. 공유 개발
database나 Compose volume을 삭제하지 않는다.
+2
View File
@@ -7,6 +7,8 @@ export interface DatabaseClient {
worldState: GamePrisma.WorldStateDelegate;
general: GamePrisma.GeneralDelegate;
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
trafficPeriod: GamePrisma.TrafficPeriodDelegate;
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate;
city: GamePrisma.CityDelegate;