@zaisma/game-server 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/room.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * running one copy of the game server. Names follow docs/NAMING.md.
8
8
  */
9
9
  import { type GameDatabase } from "./database.ts";
10
+ import { type DoorContract, type DoorShape } from "./doors.ts";
10
11
  import type { PassClaims, PlayerKind, ReceiptClaims } from "./pass.ts";
11
12
  import type { Server } from "./requests.ts";
12
13
  import { type Schedule, type ScheduledRun } from "./schedules.ts";
@@ -1117,7 +1118,7 @@ export type ClientMessage = {
1117
1118
  * the game became unavailable, its kept room was deleted, or no players were left for its spectators.
1118
1119
  */
1119
1120
  export type EndReason = "game" | "hosting_paused" | "game_server_stopped" | "game_unavailable" | "kept_room_deleted" | "no_players";
1120
- export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "bad_player_token" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
1121
+ export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "bad_player_token" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "rented_share_used" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
1121
1122
  export declare const ERROR_TEXT: Record<RoomErrorCode, string>;
1122
1123
  /** What players are told when Zaisma ends a match at room.endsAt. */
1123
1124
  export declare const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
@@ -1334,6 +1335,7 @@ export interface StateSyncHooks {
1334
1335
  export declare const NO_MESSAGE_TYPE = "(none)";
1335
1336
  export declare const BYTES_MESSAGE_TYPE = "(bytes)";
1336
1337
  export declare const ZAISMA_MESSAGE_TYPE = "(zaisma)";
1338
+ export declare const ROOM_FRAME: Readonly<Record<string, unknown>>;
1337
1339
  /**
1338
1340
  * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
1339
1341
  * doesn't build. A spectator may send only what's for room members. hello comes first and proves the pass; chat is the host's to take before receive (readChatMessage);
@@ -1348,55 +1350,89 @@ export declare const ROOM_MESSAGE_DOORS: {
1348
1350
  readonly source_path: "packages/game-server/src/pass.ts";
1349
1351
  readonly symbol_name: "verifyPass";
1350
1352
  };
1353
+ readonly takes: {
1354
+ readonly query: {};
1355
+ readonly body: {
1356
+ readonly kind: "json";
1357
+ readonly fields: {
1358
+ readonly idleTimeoutSec: DoorShape;
1359
+ readonly idleCheckEnabled: DoorShape;
1360
+ readonly type: DoorShape;
1361
+ readonly protocolVersionNumber: DoorShape;
1362
+ readonly pass: DoorShape;
1363
+ readonly playerToken: DoorShape;
1364
+ readonly joinData: DoorShape;
1365
+ readonly room: DoorShape;
1366
+ readonly isSpectator: DoorShape;
1367
+ readonly isQuickJoin: DoorShape;
1368
+ readonly avoidBlockedPlayers: DoorShape;
1369
+ readonly versionId: DoorShape;
1370
+ readonly playShare: DoorShape;
1371
+ readonly hasGraphicsChip: DoorShape;
1372
+ readonly isInputSeen: DoorShape;
1373
+ };
1374
+ readonly required: readonly ["type"];
1375
+ readonly maximumSizeBytes: number;
1376
+ };
1377
+ };
1378
+ readonly answers: import("./doors.ts").DoorAnswer;
1379
+ readonly limit: {
1380
+ readonly level: "steady";
1381
+ readonly counts: "player";
1382
+ };
1351
1383
  };
1352
- readonly probe_answer: import("./doors.ts").DoorContractFields & {
1353
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1354
- };
1355
- readonly room: import("./doors.ts").DoorContractFields & {
1356
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1357
- };
1358
- readonly kick: import("./doors.ts").DoorContractFields & {
1359
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1360
- };
1361
- readonly here: import("./doors.ts").DoorContractFields & {
1362
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1363
- };
1364
- readonly idle: import("./doors.ts").DoorContractFields & {
1365
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1366
- };
1367
- readonly hidden: import("./doors.ts").DoorContractFields & {
1368
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1369
- };
1370
- readonly shown: import("./doors.ts").DoorContractFields & {
1371
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1372
- };
1373
- readonly back: import("./doors.ts").DoorContractFields & {
1374
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1375
- };
1376
- readonly message: import("./doors.ts").DoorContractFields & {
1377
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1378
- };
1379
- readonly bytes: import("./doors.ts").DoorContractFields & {
1380
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1381
- };
1382
- readonly chat: import("./doors.ts").DoorContractFields & {
1383
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1384
- };
1385
- readonly block: import("./doors.ts").DoorContractFields & {
1386
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1387
- };
1388
- readonly unblock: import("./doors.ts").DoorContractFields & {
1389
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1390
- };
1391
- readonly friend_request: import("./doors.ts").DoorContractFields & {
1392
- whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1384
+ readonly probe_answer: DoorContract;
1385
+ readonly room: DoorContract;
1386
+ readonly kick: DoorContract;
1387
+ readonly here: DoorContract;
1388
+ readonly idle: DoorContract;
1389
+ readonly hidden: DoorContract;
1390
+ readonly shown: DoorContract;
1391
+ readonly back: DoorContract;
1392
+ readonly message: DoorContract;
1393
+ readonly bytes: {
1394
+ readonly whoMayCall: "room_player";
1395
+ readonly reaches: "room";
1396
+ readonly trusts: readonly ["player_pass"];
1397
+ readonly takes: {
1398
+ readonly query: {};
1399
+ readonly body: {
1400
+ readonly kind: "bytes";
1401
+ readonly maximumSizeBytes: number;
1402
+ };
1403
+ };
1404
+ readonly answers: import("./doors.ts").DoorAnswer;
1405
+ readonly limit: {
1406
+ readonly whyNoLimit: "room_token_bucket";
1407
+ };
1393
1408
  };
1409
+ readonly chat: DoorContract;
1410
+ readonly block: DoorContract;
1411
+ readonly unblock: DoorContract;
1412
+ readonly friend_request: DoorContract;
1394
1413
  readonly "zaisma:purchase": {
1395
- readonly whoMayCall: "room_player";
1396
1414
  readonly reaches: "purchase";
1397
1415
  readonly trusts: readonly ["player_pass", "purchase_receipt"];
1416
+ readonly takes: import("./doors.ts").DoorTakes;
1417
+ readonly answers: import("./doors.ts").DoorAnswer;
1418
+ readonly limit: import("./doors.ts").DoorLimit;
1419
+ readonly whoMayCall: import("./doors.ts").DoorCallerProvenByGuard;
1420
+ } | {
1421
+ readonly reaches: "purchase";
1422
+ readonly trusts: readonly ["player_pass", "purchase_receipt"];
1423
+ readonly takes: import("./doors.ts").DoorTakes;
1424
+ readonly answers: import("./doors.ts").DoorAnswer;
1425
+ readonly limit: import("./doors.ts").DoorLimit;
1426
+ readonly whoMayCall: import("./doors.ts").DoorCallerProvenByFunction;
1427
+ readonly provenBy: import("./doors.ts").DoorProof;
1398
1428
  };
1399
1429
  };
1430
+ /**
1431
+ * Whether a message breaks its door's shape: a field of the wrong type or past its bound, or one it needs missing. A
1432
+ * name the door doesn't know is left alone and never read, unlike at other doors: a game's SDK comes from Zaisma and
1433
+ * its server may be the developer's own, older one, so a newer SDK's field has to keep working there.
1434
+ */
1435
+ export declare function roomMessageProblem(type: Exclude<RoomMessageType, "bytes">, message: Record<string, unknown>): string | null;
1400
1436
  export type RoomMessageType = keyof typeof ROOM_MESSAGE_DOORS;
1401
1437
  export declare const OTHER_MESSAGE_TYPE = "(other)";
1402
1438
  /** State sync's updates (stateSyncEnabled). */
package/src/room.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS } from "./chat.js";
10
10
  import { DatabaseError } from "./database.js";
11
- import { checkDoor } from "./doors.js";
11
+ import { answersJson, checkDoor, fieldsOf, gameData, listOf, orNothing, realNumber, schemaProblems, text as textUpTo, yesOrNo } from "./doors.js";
12
12
  import { isRoomCode, roomCode } from "./names.js";
13
13
  import { readSchedules } from "./schedules.js";
14
14
  import { roomCallChecks } from "./room-calls.js";
@@ -322,6 +322,7 @@ export const ERROR_TEXT = {
322
322
  refused: "The game didn't let you into this room.",
323
323
  room_not_running: "That room isn't running.",
324
324
  hosting_paused: "This game's hosting is paused. It starts again when the month resets or its developer adds credit.",
325
+ rented_share_used: "This network looks like a server. Turn off your VPN or sign in with an account that has played before.",
325
326
  account_needed: "Playing online needs a Zaisma account. Sign in on the game's page to play.",
326
327
  game_not_open_to_player: "This game isn't open to this account.",
327
328
  blocked_player_here: "Someone you blocked is in this room.",
@@ -445,6 +446,9 @@ export function readChatMessage(text) {
445
446
  }
446
447
  if (!parsed || typeof parsed !== "object" || parsed.type !== "chat")
447
448
  return null;
449
+ // One of the wrong shape isn't chat: receive refuses it.
450
+ if (roomMessageProblem("chat", parsed))
451
+ return null;
448
452
  const chat = parsed;
449
453
  return { chatNumber: Number.isSafeInteger(chat.chatNumber) ? chat.chatNumber : 0, text: chat.text, toPlayerIds: chat.toPlayerIds, sizeBytes: utf8ByteCount(text) };
450
454
  }
@@ -503,32 +507,150 @@ export const STATE_SYNC = Symbol("stateSync");
503
507
  export const NO_MESSAGE_TYPE = "(none)";
504
508
  export const BYTES_MESSAGE_TYPE = "(bytes)";
505
509
  export const ZAISMA_MESSAGE_TYPE = "(zaisma)";
506
- const ROOM_MEMBER = { whoMayCall: "room_member", reaches: "room", trusts: ["player_pass"] };
507
- const ROOM_PLAYER = { whoMayCall: "room_player", reaches: "room", trusts: ["player_pass"] };
508
- const ROOM_HOST = { whoMayCall: "room_host", reaches: "room", trusts: ["player_pass"] };
510
+ /** A pass or a player token, at most, and a whole hello with the fullest of them. */
511
+ const HELLO_PROOF_MAXIMUM_CHARACTER_COUNT = 96 * 1024;
512
+ const HELLO_MAXIMUM_SIZE_BYTES = HELLO_PROOF_MAXIMUM_CHARACTER_COUNT + 2 * ROOM_LIMITS.maximumJoinDataSizeBytes + 8 * 1024;
513
+ /** What a player is sent, by its type, with each type's fields closed: a game's own data and a room's settings pass as they are. Checked in tests only, since this is the busiest path there is. */
514
+ const looseObject = { type: "object" };
515
+ const frameOf = (messageType, fields = {}, optional = {}) => ({
516
+ type: "object",
517
+ properties: { type: { type: "string", enum: [messageType] }, ...fields, ...optional },
518
+ required: ["type", ...Object.keys(fields)],
519
+ additionalProperties: false,
520
+ });
521
+ const wireText = { type: "string" };
522
+ const wireNumber = { type: "number" };
523
+ const wireYesOrNo = { type: "boolean" };
524
+ const wireNullable = (type) => ({ type: [type, "null"] });
525
+ const wirePlayers = { type: "array", items: looseObject };
526
+ const WIRE_DATA = {};
527
+ export const ROOM_FRAME = {
528
+ anyOf: [
529
+ frameOf("welcome", { me: looseObject, isSpectator: wireYesOrNo, players: wirePlayers, room: looseObject, spectatorCount: wireNumber, hostPlayerId: wireNullable("string"), awayChecks: looseObject, endsAt: wireNullable("string"), probeIntervalSec: wireNullable("number"), protocolVersionNumber: wireNumber, canChat: wireYesOrNo }),
530
+ frameOf("probe", { probeNumber: wireNumber, serverTimeMs: wireNumber, pingDurationMs: wireNullable("number") }),
531
+ frameOf("ending", { endsAt: wireText }),
532
+ frameOf("ending_cancelled"),
533
+ frameOf("version_update", { step: wireText, versionId: wireText, appliesAt: wireText, isReloading: wireYesOrNo }),
534
+ frameOf("players", { players: wirePlayers, hostPlayerId: wireNullable("string") }),
535
+ frameOf("away", { awayReason: wireText, seatHoldDurationSec: wireNumber }),
536
+ frameOf("kicked", { message: wireText, isKeptOut: wireYesOrNo }),
537
+ frameOf("room", { room: looseObject, spectatorCount: wireNumber }),
538
+ frameOf("move", { roomCode: wireText }),
539
+ frameOf("message", { data: WIRE_DATA, fromPlayerId: wireNullable("string") }),
540
+ frameOf("room_message", { roomMessage: looseObject }),
541
+ frameOf("state", { serverTimeMs: wireNumber }, { full: WIRE_DATA, changes: { type: "array" } }),
542
+ frameOf("blocks", { playerIds: { type: "array", items: wireText } }),
543
+ frameOf("friend_request"),
544
+ frameOf("chat", { fromPlayerId: wireText, text: wireText, hasSwearing: wireYesOrNo, hasSlur: wireYesOrNo, sentAt: wireText }),
545
+ frameOf("chat_sent", { chatNumber: wireNumber, sentAt: wireText }),
546
+ frameOf("chat_stopped", { chatNumber: wireNumber, reason: wireText, message: wireText }),
547
+ frameOf("error", { code: wireText, message: wireText }, { versionId: wireText, chatNumber: wireNumber }),
548
+ frameOf("ended", { message: wireText, endReason: wireText }),
549
+ ],
550
+ };
551
+ /** The room's settings as a client may ask for them: tidyRoomInfo cuts a name and labels to their limits, and these are only the outer wall. */
552
+ const ASKED_ROOM = fieldsOf({
553
+ isListed: yesOrNo,
554
+ isOpen: yesOrNo,
555
+ friendJoinsEnabled: yesOrNo,
556
+ name: textUpTo(1000),
557
+ labels: gameData(4 * 1024),
558
+ });
559
+ const PLAYER_ID_SHAPE = textUpTo(80);
560
+ const IDLE_FIELDS = { idleTimeoutSec: realNumber(0, Number.MAX_SAFE_INTEGER), idleCheckEnabled: yesOrNo };
561
+ /** A connection's limit is a room's message bucket, which takes a token per player before any of these runs. */
562
+ const roomMessage = (whoMayCall, fields = {}, required = []) => ({
563
+ whoMayCall,
564
+ reaches: "room",
565
+ trusts: ["player_pass"],
566
+ takes: { query: {}, body: { kind: "json", fields: { type: textUpTo(40), ...fields }, required: ["type", ...required], maximumSizeBytes: ROOM_LIMITS.maximumGameMessageSizeBytes } },
567
+ answers: answersJson(ROOM_FRAME),
568
+ limit: { whyNoLimit: "room_token_bucket" },
569
+ });
509
570
  /**
510
571
  * Every message a room takes (docs/DOORS.md). receive's switch is over these names, so a new one without a row
511
572
  * doesn't build. A spectator may send only what's for room members. hello comes first and proves the pass; chat is the host's to take before receive (readChatMessage);
512
573
  * bytes is every binary message (receiveBytes). A type with no row, maybe from a newer SDK, is counted and ignored.
513
574
  */
514
575
  export const ROOM_MESSAGE_DOORS = {
515
- hello: { whoMayCall: "pass_holder", reaches: "room", trusts: ["player_pass"], provenBy: { source_path: "packages/game-server/src/pass.ts", symbol_name: "verifyPass" } },
516
- probe_answer: ROOM_MEMBER,
517
- room: ROOM_HOST,
518
- kick: ROOM_HOST,
519
- here: ROOM_PLAYER,
520
- idle: ROOM_PLAYER,
521
- hidden: ROOM_PLAYER,
522
- shown: ROOM_PLAYER,
523
- back: ROOM_PLAYER,
524
- message: ROOM_PLAYER,
525
- bytes: ROOM_PLAYER,
526
- chat: ROOM_PLAYER,
527
- block: ROOM_PLAYER,
528
- unblock: ROOM_PLAYER,
529
- friend_request: ROOM_PLAYER,
530
- "zaisma:purchase": { whoMayCall: "room_player", reaches: "purchase", trusts: ["player_pass", "purchase_receipt"] },
576
+ hello: {
577
+ whoMayCall: "pass_holder",
578
+ reaches: "room",
579
+ trusts: ["player_pass"],
580
+ provenBy: { source_path: "packages/game-server/src/pass.ts", symbol_name: "verifyPass" },
581
+ takes: {
582
+ query: {},
583
+ body: {
584
+ kind: "json",
585
+ fields: {
586
+ type: textUpTo(40),
587
+ protocolVersionNumber: realNumber(0, Number.MAX_SAFE_INTEGER),
588
+ pass: textUpTo(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT),
589
+ playerToken: orNothing(textUpTo(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT)),
590
+ joinData: gameData(ROOM_LIMITS.maximumJoinDataSizeBytes * 2),
591
+ room: ASKED_ROOM,
592
+ isSpectator: yesOrNo,
593
+ isQuickJoin: yesOrNo,
594
+ avoidBlockedPlayers: yesOrNo,
595
+ versionId: textUpTo(80),
596
+ playShare: textUpTo(400),
597
+ hasGraphicsChip: yesOrNo,
598
+ isInputSeen: yesOrNo,
599
+ ...IDLE_FIELDS,
600
+ },
601
+ required: ["type"],
602
+ maximumSizeBytes: HELLO_MAXIMUM_SIZE_BYTES,
603
+ },
604
+ },
605
+ answers: answersJson(ROOM_FRAME),
606
+ limit: { level: "steady", counts: "player" },
607
+ },
608
+ probe_answer: roomMessage("room_member", { probeNumber: realNumber(0, Number.MAX_SAFE_INTEGER) }),
609
+ room: roomMessage("room_host", { room: ASKED_ROOM }, ["room"]),
610
+ kick: roomMessage("room_host", { playerId: PLAYER_ID_SHAPE, keepOut: yesOrNo }, ["playerId"]),
611
+ here: roomMessage("room_player"),
612
+ idle: roomMessage("room_player"),
613
+ hidden: roomMessage("room_player"),
614
+ shown: roomMessage("room_player"),
615
+ back: roomMessage("room_player", { joinData: gameData(ROOM_LIMITS.maximumJoinDataSizeBytes * 2), ...IDLE_FIELDS }),
616
+ message: roomMessage("room_player", { data: gameData(ROOM_LIMITS.maximumGameMessageSizeBytes) }),
617
+ bytes: {
618
+ whoMayCall: "room_player",
619
+ reaches: "room",
620
+ trusts: ["player_pass"],
621
+ takes: { query: {}, body: { kind: "bytes", maximumSizeBytes: ROOM_LIMITS.maximumGameMessageSizeBytes } },
622
+ answers: answersJson(ROOM_FRAME),
623
+ limit: { whyNoLimit: "room_token_bucket" },
624
+ },
625
+ chat: roomMessage("room_player", { chatNumber: realNumber(0, Number.MAX_SAFE_INTEGER), text: textUpTo(ROOM_LIMITS.maximumGameMessageSizeBytes), toPlayerIds: listOf(PLAYER_ID_SHAPE, 200) }, ["text"]),
626
+ block: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
627
+ unblock: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
628
+ friend_request: roomMessage("room_player", { playerId: PLAYER_ID_SHAPE }, ["playerId"]),
629
+ "zaisma:purchase": {
630
+ ...roomMessage("room_player", { purchaseId: gameData(4096), receipt: gameData(HELLO_PROOF_MAXIMUM_CHARACTER_COUNT) }),
631
+ reaches: "purchase",
632
+ trusts: ["player_pass", "purchase_receipt"],
633
+ },
531
634
  };
635
+ /**
636
+ * Whether a message breaks its door's shape: a field of the wrong type or past its bound, or one it needs missing. A
637
+ * name the door doesn't know is left alone and never read, unlike at other doors: a game's SDK comes from Zaisma and
638
+ * its server may be the developer's own, older one, so a newer SDK's field has to keep working there.
639
+ */
640
+ export function roomMessageProblem(type, message) {
641
+ const body = ROOM_MESSAGE_DOORS[type].takes.body;
642
+ for (const name of body.required ?? [])
643
+ if (!(name in message))
644
+ return `${name} is missing`;
645
+ for (const [name, shape] of Object.entries(body.fields)) {
646
+ if (!(name in message))
647
+ continue;
648
+ const [problem] = schemaProblems(shape, message[name], name);
649
+ if (problem)
650
+ return problem;
651
+ }
652
+ return null;
653
+ }
532
654
  const isRoomMessageType = (type) => typeof type === "string" && Object.hasOwn(ROOM_MESSAGE_DOORS, type);
533
655
  const PLAYER_PROVEN = ["room_member", "room_player"];
534
656
  const HOST_PROVEN = ["room_member", "room_player", "room_host"];
@@ -1792,6 +1914,8 @@ export class RoomCore {
1792
1914
  const type = isRoomMessageType(message.type) ? message.type : null;
1793
1915
  if (type && !checkDoor(ROOM_MESSAGE_DOORS[type], this.hostPlayerId === playerId ? HOST_PROVEN : PLAYER_PROVEN))
1794
1916
  return "not_allowed";
1917
+ if (type && type !== "bytes" && roomMessageProblem(type, message))
1918
+ return "bad_message";
1795
1919
  switch (type) {
1796
1920
  case "room":
1797
1921
  return this.hostChange(playerId, message);
Binary file
Binary file
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="-120 600 4527 3300" role="img" aria-label="Zaisma"><g fill="#000000"><path d="M1887.28,2962.39l2198.79,0c66.882,272.078 133.765,544.155 200.647,816.233c-1428.9,-0 -2857.81,-0 -4286.71,-0c667.483,-278.522 1295.53,-550.506 1887.28,-816.233Z"/><path d="M471.953,3302.71c783.648,-687.125 1431.04,-1310.93 1977.27,-1875.69c359.003,-140.007 706.42,-278.207 1043.12,-414.664c-427.425,518.594 -931.316,1086.1 -1541.37,1707.29c-489.281,198.206 -983.052,392.621 -1479.02,583.066Z"/><path d="M2486.31,1242.1l-1484.53,-0c30.202,-173.574 60.405,-347.147 90.607,-520.721l2582.62,0c-379.41,170.187 -774.918,343.675 -1188.69,520.721Z"/></g></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="-120 600 4527 3300" role="img" aria-label="Zaisma"><g fill="#FFFFFF"><path d="M1887.28,2962.39l2198.79,0c66.882,272.078 133.765,544.155 200.647,816.233c-1428.9,-0 -2857.81,-0 -4286.71,-0c667.483,-278.522 1295.53,-550.506 1887.28,-816.233Z"/><path d="M471.953,3302.71c783.648,-687.125 1431.04,-1310.93 1977.27,-1875.69c359.003,-140.007 706.42,-278.207 1043.12,-414.664c-427.425,518.594 -931.316,1086.1 -1541.37,1707.29c-489.281,198.206 -983.052,392.621 -1479.02,583.066Z"/><path d="M2486.31,1242.1l-1484.53,-0c30.202,-173.574 60.405,-347.147 90.607,-520.721l2582.62,0c-379.41,170.187 -774.918,343.675 -1188.69,520.721Z"/></g></svg>