@zaisma/game-server 1.14.0 → 1.15.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.
Files changed (2) hide show
  1. package/bench/glass.js +217 -43
  2. package/package.json +1 -1
package/bench/glass.js CHANGED
@@ -6315,6 +6315,9 @@ var ERROR_CODES = {
6315
6315
  no_top_up: { httpStatus: 404, meaning: "You have no top-up with that ID.", messageForPeople: "You have no such top-up." },
6316
6316
  no_spending_request: { httpStatus: 404, meaning: "You have no spending request with that ID.", messageForPeople: "You have no such spending request." },
6317
6317
  top_up_not_refundable: { httpStatus: 409, meaning: "The top-up has nothing left to refund: it's spent, refunded, or over 30 days old.", messageForPeople: "This top-up has nothing left to refund: it's spent, refunded or over 30 days old." },
6318
+ bad_refill_level_cents: { httpStatus: 400, meaning: "refill_level_cents isn't a whole number of cents from 1,000 ($10) to 1,000,000 ($10,000).", messageForPeople: "A monthly plan's level is from $10 to $10,000." },
6319
+ already_credit_plan: { httpStatus: 409, meaning: "The developer already has a monthly credit plan, so there's nothing to start: change its level or stop it.", messageForPeople: "You already have a monthly credit plan." },
6320
+ no_credit_plan: { httpStatus: 409, meaning: "The developer has no monthly credit plan to change or stop.", messageForPeople: "There's no monthly credit plan to change or stop." },
6318
6321
  already_pro: { httpStatus: 409, meaning: "The developer already has a Zaisma Pro subscription (running, cancelled until the month ends, or a renewal Stripe is retrying), so there's nothing to start.", messageForPeople: "You already have Zaisma Pro." },
6319
6322
  pro_not_refundable: { httpStatus: 409, meaning: "Zaisma Pro's payment can't be refunded: only the first payment, within 14 days of it, and only while it's the only one.", messageForPeople: "Only the first Zaisma Pro payment is refunded, within 14 days of it, while it's the only one." },
6320
6323
  not_pro: { httpStatus: 409, meaning: "There's nothing to cancel or resume: the developer has no Zaisma Pro, or it's already cancelled (or isn't cancelled).", messageForPeople: "There's no Zaisma Pro here to cancel or resume." },
@@ -7466,6 +7469,60 @@ function markSha256(kind, given) {
7466
7469
  return sha256Hex(new TextEncoder().encode(kind === "email" ? given.trim().toLowerCase() : given));
7467
7470
  }
7468
7471
 
7472
+ // ../core/src/standing.ts
7473
+ var STANDING_LIMITS = {
7474
+ minimumDeveloperAccountAgeDays: 14,
7475
+ minimumReturningPlayerCount: 20,
7476
+ /** Different days a player's plays counted on the developer's games, for the player to be a returning one. */
7477
+ minimumReturningPlayerCountedDayCount: 2,
7478
+ /** A returning player's own account: this old, with plays counted on this many different days on any game. */
7479
+ minimumPlayerAccountAgeDays: 7,
7480
+ minimumPlayerCountedDayCount: 3,
7481
+ /** After a version is rejected or a game taken down, the developer's powers wait this long. */
7482
+ rulingWaitDurationDays: 30,
7483
+ /** A new 16–17 account set to chat with anyone chats only with friends until it's this old, with plays counted on this many different days. */
7484
+ minimumStrangerChatAccountAgeHours: 24,
7485
+ minimumStrangerChatCountedDayCount: 2
7486
+ };
7487
+ var DAY_MS = 864e5;
7488
+ var NO_STANDING = {
7489
+ isEstablishedDeveloper: false,
7490
+ outsideLinksEnabled: false,
7491
+ searchEnginesEnabled: false,
7492
+ returningPlayerCount: 0,
7493
+ accountAgeDays: 0,
7494
+ lastRulingAtMs: null,
7495
+ rulingWaitEndsAtMs: null,
7496
+ hasCardOrStripeAccount: false,
7497
+ isBanned: false
7498
+ };
7499
+ function rulingWaitEndsAt(lastRulingAtMs, nowMs) {
7500
+ if (lastRulingAtMs === null) return null;
7501
+ const endsAtMs = lastRulingAtMs + STANDING_LIMITS.rulingWaitDurationDays * DAY_MS;
7502
+ return endsAtMs > nowMs ? endsAtMs : null;
7503
+ }
7504
+ async function standingOf(database, ownerId, nowMs) {
7505
+ if (!ownerId) return NO_STANDING;
7506
+ const row2 = await database.prepare(
7507
+ `SELECT owners.is_established, owners.is_zaisma_account, owners.returning_player_count, owners.last_ruling_at_ms, owners.created_at_ms AS owner_created_at_ms,
7508
+ owners.stripe_account_id, owners.can_take_stripe_payments, accounts.created_at_ms AS account_created_at_ms, accounts.banned_at_ms,
7509
+ EXISTS (SELECT 1 FROM owner_cards WHERE owner_cards.owner_id = owners.id AND NOT EXISTS (SELECT 1 FROM banned_marks WHERE banned_marks.mark_kind = 'card' AND banned_marks.mark_sha256 = owner_cards.card_fingerprint_sha256)) AS has_card
7510
+ FROM owners LEFT JOIN accounts ON accounts.id = owners.account_id WHERE owners.id = ?`
7511
+ ).bind(ownerId).first();
7512
+ if (!row2) return NO_STANDING;
7513
+ const accountAgeDays = Math.max(0, Math.floor((nowMs - (row2.account_created_at_ms ?? row2.owner_created_at_ms)) / DAY_MS));
7514
+ const rulingWaitEndsAtMs = rulingWaitEndsAt(row2.last_ruling_at_ms, nowMs);
7515
+ const hasCardOrStripeAccount = row2.has_card === 1 || row2.stripe_account_id !== null && row2.can_take_stripe_payments === 1;
7516
+ const isBanned = row2.banned_at_ms !== null;
7517
+ const facts2 = { returningPlayerCount: row2.returning_player_count, accountAgeDays, lastRulingAtMs: row2.last_ruling_at_ms, rulingWaitEndsAtMs, hasCardOrStripeAccount, isBanned };
7518
+ if (isBanned || rulingWaitEndsAtMs !== null) return { ...NO_STANDING, ...facts2 };
7519
+ const isEstablishedDeveloper = row2.is_established === 1 || row2.is_zaisma_account === 1;
7520
+ return { ...facts2, isEstablishedDeveloper, outsideLinksEnabled: isEstablishedDeveloper || hasCardOrStripeAccount, searchEnginesEnabled: isEstablishedDeveloper };
7521
+ }
7522
+ async function noteRuling(database, ownerId, nowMs) {
7523
+ await database.prepare("UPDATE owners SET last_ruling_at_ms = ?, is_established = 0 WHERE id = ?").bind(nowMs, ownerId).run();
7524
+ }
7525
+
7469
7526
  // ../core/src/blocks.ts
7470
7527
  var MAXIMUM_OTHER_PLAYER_COUNT = 100;
7471
7528
 
@@ -7714,6 +7771,9 @@ var SECURITY_EVENT_DESCRIPTIONS = {
7714
7771
  where_used_from_turned_on: "Turned on Keep where my account is used from",
7715
7772
  credit_top_up_started: "Started a credit top-up",
7716
7773
  credit_refunded: "Refunded hosting credit",
7774
+ credit_plan_started: "Started a monthly credit plan",
7775
+ credit_plan_changed: "Changed the monthly credit plan's level",
7776
+ credit_plan_stopped: "Stopped the monthly credit plan",
7717
7777
  pro_checkout_started: "Started Zaisma Pro",
7718
7778
  pro_cancelled: "Cancelled Zaisma Pro",
7719
7779
  pro_resumed: "Took back cancelling Zaisma Pro",
@@ -7796,6 +7856,9 @@ var ACCOUNT_EVENT_KINDS = [
7796
7856
  "account_data_downloaded",
7797
7857
  "credit_top_up_started",
7798
7858
  "credit_refunded",
7859
+ "credit_plan_started",
7860
+ "credit_plan_changed",
7861
+ "credit_plan_stopped",
7799
7862
  "pro_checkout_started",
7800
7863
  "pro_cancelled",
7801
7864
  "pro_resumed",
@@ -7866,12 +7929,13 @@ var TRUSTED_LINK_DOMAINS = [
7866
7929
  var linksOf = (game) => JSON.parse(game.links || "[]");
7867
7930
  var isUnder = (host, domain) => host === domain || host.endsWith(`.${domain}`);
7868
7931
  var isTrustedLinkHost = (host) => TRUSTED_LINK_DOMAINS.some((domain) => isUnder(host, domain));
7869
- function linkAudience(link) {
7932
+ function linkAudience(link, outsideLinksEnabled) {
7870
7933
  if (link.review_reason) return "nobody";
7871
- return isTrustedLinkHost(new URL(link.destination_url).hostname) ? "everyone" : "signed_in_adults";
7934
+ if (isTrustedLinkHost(new URL(link.destination_url).hostname)) return "everyone";
7935
+ return outsideLinksEnabled ? "signed_in_adults" : "waiting_for_standing";
7872
7936
  }
7873
- function canOpenLink(link, isSignedInAdult) {
7874
- const audience = linkAudience(link);
7937
+ function canOpenLink(link, isSignedInAdult, outsideLinksEnabled) {
7938
+ const audience = linkAudience(link, outsideLinksEnabled);
7875
7939
  return audience === "everyone" || audience === "signed_in_adults" && isSignedInAdult;
7876
7940
  }
7877
7941
  var BRAND_DOMAINS = [
@@ -8464,7 +8528,7 @@ var BROTLI_LIMITS = {
8464
8528
  var MAXIMUM_DATABASE_SIZE_BYTES = 10 * 1024 ** 3;
8465
8529
 
8466
8530
  // ../core/src/hosting.ts
8467
- var DAY_MS = 24 * 36e5;
8531
+ var DAY_MS2 = 24 * 36e5;
8468
8532
  var USAGE_FIELDS = {
8469
8533
  room_play_duration_sec: "roomPlayDurationSec",
8470
8534
  room_awake_duration_sec: "roomAwakeDurationSec",
@@ -8686,12 +8750,12 @@ function readCron(cron) {
8686
8750
  return { cronTimes };
8687
8751
  }
8688
8752
  var SEARCH_DAY_COUNT = 8 * 366;
8689
- var DAY_MS2 = 864e5;
8753
+ var DAY_MS3 = 864e5;
8690
8754
  function nextAtMs(cronTimes, afterMs) {
8691
8755
  const firstMs = Math.floor(afterMs / 6e4) * 6e4 + 6e4;
8692
- const firstDayMs = Math.floor(firstMs / DAY_MS2) * DAY_MS2;
8756
+ const firstDayMs = Math.floor(firstMs / DAY_MS3) * DAY_MS3;
8693
8757
  for (let dayIndex = 0; dayIndex < SEARCH_DAY_COUNT; dayIndex++) {
8694
- const dayMs = firstDayMs + dayIndex * DAY_MS2;
8758
+ const dayMs = firstDayMs + dayIndex * DAY_MS3;
8695
8759
  const date2 = new Date(dayMs);
8696
8760
  if (!cronTimes.months[date2.getUTCMonth() + 1]) continue;
8697
8761
  const isMonthDay = cronTimes.monthDays[date2.getUTCDate()];
@@ -26292,7 +26356,7 @@ var MAXIMUM_START_DELAY_DAYS = 90;
26292
26356
  var START_GRACE_DURATION_MS = 36e5;
26293
26357
  var ENDED_SHOWN_DURATION_DAYS = 30;
26294
26358
  var DEFAULT_LINK_LABEL = "Read more";
26295
- var DAY_MS3 = 864e5;
26359
+ var DAY_MS4 = 864e5;
26296
26360
  var shownLinkLabel = (announcement) => announcement.link_url ? announcement.link_label ?? DEFAULT_LINK_LABEL : null;
26297
26361
  async function currentAnnouncements(env2, nowMs) {
26298
26362
  const { results } = await env2.DB.prepare("SELECT * FROM announcements WHERE ends_at_ms > ? AND starts_at_ms <= ? ORDER BY starts_at_ms DESC, created_at_ms DESC LIMIT 20").bind(nowMs, nowMs).all();
@@ -26304,7 +26368,7 @@ function announcementStatus(announcement, nowMs) {
26304
26368
  }
26305
26369
  async function listAnnouncements(env2) {
26306
26370
  const nowMs = Date.now();
26307
- const { results } = await env2.DB.prepare("SELECT * FROM announcements WHERE ends_at_ms > ? ORDER BY starts_at_ms DESC, created_at_ms DESC LIMIT 100").bind(nowMs - ENDED_SHOWN_DURATION_DAYS * DAY_MS3).all();
26371
+ const { results } = await env2.DB.prepare("SELECT * FROM announcements WHERE ends_at_ms > ? ORDER BY starts_at_ms DESC, created_at_ms DESC LIMIT 100").bind(nowMs - ENDED_SHOWN_DURATION_DAYS * DAY_MS4).all();
26308
26372
  const [shown] = await currentAnnouncements(env2, nowMs);
26309
26373
  return { announcements: results, shownAnnouncementId: shown?.id ?? null, nowMs };
26310
26374
  }
@@ -26317,7 +26381,7 @@ function readTime(given, field) {
26317
26381
  const atMs = typeof given === "string" && ISO_TIME.test(given) ? Date.parse(given) : NaN;
26318
26382
  if (!Number.isFinite(atMs)) {
26319
26383
  const said = typeof given === "string" ? `"${given.slice(0, 40)}"` : `a ${typeof given}`;
26320
- throw new ApiError("bad_time", { message: `${field} is ${said}, not an ISO time.`, field, fix: `Give an ISO time with Z or an offset, like "${new Date(Date.now() + 7 * DAY_MS3).toISOString().slice(0, 14)}00:00Z".` });
26384
+ throw new ApiError("bad_time", { message: `${field} is ${said}, not an ISO time.`, field, fix: `Give an ISO time with Z or an offset, like "${new Date(Date.now() + 7 * DAY_MS4).toISOString().slice(0, 14)}00:00Z".` });
26321
26385
  }
26322
26386
  return atMs;
26323
26387
  }
@@ -26356,11 +26420,11 @@ async function postAnnouncement(env2, toPost, actor) {
26356
26420
  throw new ApiError("bad_link_label", { message: `link_label is ${linkLabel.length} characters, not 1 to ${MAXIMUM_LINK_LABEL_CHARACTER_COUNT}.`, field: "link_label", fix: `Give 1 to ${MAXIMUM_LINK_LABEL_CHARACTER_COUNT} characters, like "See what's new", or leave it out for "${DEFAULT_LINK_LABEL}".` });
26357
26421
  }
26358
26422
  const startsAtMs = toPost.startsAt === void 0 ? nowMs : readTime(toPost.startsAt, "starts_at");
26359
- if (startsAtMs < nowMs - START_GRACE_DURATION_MS || startsAtMs > nowMs + MAXIMUM_START_DELAY_DAYS * DAY_MS3) {
26423
+ if (startsAtMs < nowMs - START_GRACE_DURATION_MS || startsAtMs > nowMs + MAXIMUM_START_DELAY_DAYS * DAY_MS4) {
26360
26424
  throw new ApiError("bad_time", { message: `starts_at ${iso(startsAtMs)} isn't from an hour ago to ${MAXIMUM_START_DELAY_DAYS} days ahead.`, field: "starts_at", fix: "Leave starts_at out to show it now, or give a time up to 90 days ahead." });
26361
26425
  }
26362
26426
  const endsAtMs = readTime(toPost.endsAt, "ends_at");
26363
- const latestEndAtMs = startsAtMs + MAXIMUM_ANNOUNCEMENT_DURATION_DAYS * DAY_MS3;
26427
+ const latestEndAtMs = startsAtMs + MAXIMUM_ANNOUNCEMENT_DURATION_DAYS * DAY_MS4;
26364
26428
  if (endsAtMs <= startsAtMs || endsAtMs <= nowMs || endsAtMs > latestEndAtMs) {
26365
26429
  throw new ApiError("bad_time", {
26366
26430
  message: endsAtMs > latestEndAtMs ? `ends_at ${iso(endsAtMs)} is more than ${MAXIMUM_ANNOUNCEMENT_DURATION_DAYS} days after starts_at.` : `ends_at ${iso(endsAtMs)} isn't after ${endsAtMs <= nowMs ? "now" : "starts_at"}.`,
@@ -27588,6 +27652,12 @@ function encodeHeader(text4) {
27588
27652
  return `=?UTF-8?B?${b64}?=`;
27589
27653
  }
27590
27654
 
27655
+ // ../../apps/site/src/standing.ts
27656
+ async function noteRulingOnGame(env2, gameId, nowMs = Date.now()) {
27657
+ const game = await env2.DB.prepare("SELECT owner_id FROM games WHERE id = ?").bind(gameId).first();
27658
+ if (game?.owner_id) await noteRuling(env2.DB, game.owner_id, nowMs);
27659
+ }
27660
+
27591
27661
  // ../../apps/site/src/upload.ts
27592
27662
  async function deletePrefix(bucket, prefix) {
27593
27663
  let cursor;
@@ -27824,7 +27894,7 @@ async function reviewPrecedentAction(env2, path, form, actor) {
27824
27894
  var CODE_DURATION_MIN = 10;
27825
27895
  var CODE_DURATION_MS = CODE_DURATION_MIN * 6e4;
27826
27896
  var HOUR_MS4 = 36e5;
27827
- var DAY_MS4 = 24 * HOUR_MS4;
27897
+ var DAY_MS5 = 24 * HOUR_MS4;
27828
27898
  async function deliverEmail(env2, email, message2) {
27829
27899
  try {
27830
27900
  if (env2.EMAIL_OUTBOX_URL) {
@@ -27910,6 +27980,7 @@ async function stopCopy(env2, version, actor) {
27910
27980
  const rejectedReason = decidedReason("rejected_by_zaisma", "copyright", "A re-upload of another developer's game.");
27911
27981
  await saveScan(env2.DB, version.id, "rejected", null);
27912
27982
  await env2.DB.prepare("UPDATE versions SET review_reason = ? WHERE id = ?").bind(JSON.stringify(rejectedReason), version.id).run();
27983
+ await noteRulingOnGame(env2, version.game_id);
27913
27984
  await audit(env2.DB, version.game_id, "rejected", `${version.id}: a re-upload of another developer's game`, actor, rejectedReason);
27914
27985
  await tellWebhooks(env2, "scan_status_changed", version.game_id, { versionId: version.id });
27915
27986
  const original = await gameById(env2.DB, report.matched_game.game_id);
@@ -46696,9 +46767,9 @@ function previewLinkToken(version, nowMs = Date.now()) {
46696
46767
  }
46697
46768
 
46698
46769
  // ../../apps/site/src/performance-check.ts
46699
- var DAY_MS5 = 24 * 3600 * 1e3;
46770
+ var DAY_MS6 = 24 * 3600 * 1e3;
46700
46771
  async function dailyCheckCount(env2, ownerId, nowMs = Date.now()) {
46701
- const counted = await env2.DB.prepare("SELECT COUNT(*) AS check_count FROM performance_checks WHERE owner_id = ? AND queued_at_ms >= ? AND check_status != 'over_daily_limit'").bind(ownerId, nowMs - nowMs % DAY_MS5).first();
46772
+ const counted = await env2.DB.prepare("SELECT COUNT(*) AS check_count FROM performance_checks WHERE owner_id = ? AND queued_at_ms >= ? AND check_status != 'over_daily_limit'").bind(ownerId, nowMs - nowMs % DAY_MS6).first();
46702
46773
  return counted?.check_count ?? 0;
46703
46774
  }
46704
46775
  async function queuePerformanceCheck(env2, version) {
@@ -46796,9 +46867,9 @@ Keep the summary and why short and plain.`;
46796
46867
 
46797
46868
  // ../../apps/site/src/security-alerts.ts
46798
46869
  var HOUR_MS6 = 36e5;
46799
- var DAY_MS6 = 24 * HOUR_MS6;
46870
+ var DAY_MS7 = 24 * HOUR_MS6;
46800
46871
  async function securitySpikesAnswer(env2, nowMs = Date.now()) {
46801
- const { results } = await env2.DB.prepare("SELECT * FROM security_spikes WHERE alerted_at_ms IS NOT NULL AND last_happened_at_ms > ? ORDER BY hour_number DESC, occurrence_count DESC LIMIT 100").bind(nowMs - SECURITY_SIGNAL_KEPT_DURATION_DAYS * DAY_MS6).all();
46872
+ const { results } = await env2.DB.prepare("SELECT * FROM security_spikes WHERE alerted_at_ms IS NOT NULL AND last_happened_at_ms > ? ORDER BY hour_number DESC, occurrence_count DESC LIMIT 100").bind(nowMs - SECURITY_SIGNAL_KEPT_DURATION_DAYS * DAY_MS7).all();
46802
46873
  return {
46803
46874
  security_spikes: results.map((row2) => ({
46804
46875
  spike_kind: row2.spike_kind,
@@ -46814,7 +46885,7 @@ async function blockedScriptsAnswer(env2, nowMs = Date.now()) {
46814
46885
  const { results } = await env2.DB.prepare(
46815
46886
  `SELECT violated_directive, blocked_source, page_path, SUM(occurrence_count) AS occurrence_count, MIN(first_reported_at_ms) AS first_reported_at_ms, MAX(last_reported_at_ms) AS last_reported_at_ms
46816
46887
  FROM blocked_scripts WHERE last_reported_at_ms > ? GROUP BY violated_directive, blocked_source, page_path ORDER BY occurrence_count DESC LIMIT 100`
46817
- ).bind(nowMs - 7 * DAY_MS6).all();
46888
+ ).bind(nowMs - 7 * DAY_MS7).all();
46818
46889
  return {
46819
46890
  blocked_scripts: results.map((row2) => ({
46820
46891
  violated_directive: row2.violated_directive,
@@ -46828,9 +46899,9 @@ async function blockedScriptsAnswer(env2, nowMs = Date.now()) {
46828
46899
  }
46829
46900
 
46830
46901
  // ../../apps/site/src/device-marks.ts
46831
- var DAY_MS7 = 864e5;
46902
+ var DAY_MS8 = 864e5;
46832
46903
  var MARK_DURATION_DAYS = 365;
46833
- var MARK_DURATION_MS = MARK_DURATION_DAYS * DAY_MS7;
46904
+ var MARK_DURATION_MS = MARK_DURATION_DAYS * DAY_MS8;
46834
46905
 
46835
46906
  // ../../apps/site/src/leaked-keys.ts
46836
46907
  var MAXIMUM_GITHUB_BODY_SIZE_BYTES = 1024 * 1024;
@@ -47616,6 +47687,9 @@ var API_ROUTES = [
47616
47687
  call({ method: "POST", path: "/pro/resume", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true }),
47617
47688
  call({ method: "POST", path: "/pro/refund", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true }),
47618
47689
  call({ method: "POST", path: "/credit/refunds", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true, body: { inputs: plainInputs("top_up_id") } }),
47690
+ call({ method: "POST", path: "/credit/plan/checkout", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true, body: { inputs: plainInputs("refill_level_cents") } }),
47691
+ call({ method: "PATCH", path: "/credit/plan", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true, body: { inputs: plainInputs("refill_level_cents") } }),
47692
+ call({ method: "POST", path: "/credit/plan/stop", reaches: "account", access: needs("credit_access", "write"), isAccountWide: true }),
47619
47693
  call({ method: "GET", path: "/stripe-account", reaches: "account", access: needs("stripe_account_access", "read"), isAccountWide: true }),
47620
47694
  call({ method: "POST", path: "/stripe-account/connection-links", reaches: "account", access: needs("stripe_account_access", "write"), isAccountWide: true }),
47621
47695
  call({ method: "PATCH", path: "/stripe-account", reaches: "account", access: needs("stripe_account_access", "write"), isAccountWide: true, body: { inputs: plainInputs("is_stripe_tax_used") } }),
@@ -47734,6 +47808,7 @@ function findingsFor(report) {
47734
47808
  }
47735
47809
  var splitLabels = (labels) => labels ? labels.split(",") : [];
47736
47810
  async function gameJson(env2, game) {
47811
+ const { outsideLinksEnabled } = await standingOf(env2.DB, game.owner_id, Date.now());
47737
47812
  const waitingWords = game.details_waiting_for_review ? JSON.parse(game.details_waiting_for_review) : null;
47738
47813
  return {
47739
47814
  slug: game.slug,
@@ -47763,7 +47838,7 @@ async function gameJson(env2, game) {
47763
47838
  minimum_player_age_years: game.minimum_player_age_years,
47764
47839
  game_server_allowed_hosts: allowedHostsOf(game),
47765
47840
  own_server_urls: ownServerUrlsOf(game),
47766
- links: linksOf(game).map(linkJson),
47841
+ links: linksOf(game).map((link) => linkJson(link, outsideLinksEnabled)),
47767
47842
  details_waiting_for_review: waitingWords ? { title: waitingWords.title ?? null, description: waitingWords.description ?? null } : null,
47768
47843
  // Why the title keeps the game off Zaisma's lists ("No slop"); null when nothing does.
47769
47844
  title_listing_reason: reasonAnswer(game.title_listing_reason),
@@ -47780,12 +47855,12 @@ async function gameJson(env2, game) {
47780
47855
  updated_at: iso(game.updated_at_ms)
47781
47856
  };
47782
47857
  }
47783
- function linkJson(link) {
47858
+ function linkJson(link, outsideLinksEnabled) {
47784
47859
  return {
47785
47860
  link_name: link.link_name,
47786
47861
  destination_url: link.destination_url,
47787
47862
  is_on_game_page: link.is_on_game_page,
47788
- who_can_open: linkAudience(link),
47863
+ who_can_open: linkAudience(link, outsideLinksEnabled),
47789
47864
  review_reason: link.review_reason ? reviewReasonAnswer(link.review_reason) : null
47790
47865
  };
47791
47866
  }
@@ -47822,7 +47897,7 @@ async function tellWebhooks(env2, kind, gameId, parts2, waitUntil) {
47822
47897
  console.error("Couldn't tell webhooks", kind, gameId, error);
47823
47898
  }
47824
47899
  }
47825
- var DAY_MS8 = 24 * 3600 * 1e3;
47900
+ var DAY_MS9 = 24 * 3600 * 1e3;
47826
47901
  async function sendAll(env2, webhooks, deliveries) {
47827
47902
  const sendToGameServer = gameServerSender(env2);
47828
47903
  const tries = await Promise.allSettled(deliveries.map((delivery, i) => attemptDelivery(env2.DB, webhooks[i], delivery, { sendToGameServer })));
@@ -50036,7 +50111,7 @@ function gamePageResponse(env2, view) {
50036
50111
  ...purchaseLabel.categories.length && refundWords ? [{ label: "Refunds", text: refundWords }] : []
50037
50112
  ];
50038
50113
  const isSignedInAdult = viewer.account?.age_band === "18+";
50039
- const pageLinks = linksOf(game).filter((link) => link.is_on_game_page && canOpenLink(link, isSignedInAdult));
50114
+ const pageLinks = linksOf(game).filter((link) => link.is_on_game_page && canOpenLink(link, isSignedInAdult, view.outsideLinksEnabled));
50040
50115
  const gameOrigin = new URL(frameUrl).origin;
50041
50116
  const testerWords = tester ? `You're testing this game with a link from its developer.${isTestBuild ? ` It opens a version players don't have yet${game.multiplayer_mode !== "none" || ownServers.length ? ", so online play is off: it works only on the version players have" : ""}.` : ""}${hidden ? " It isn't public yet: anyone with this link can play, so only share it with other testers." : ""} Your progress stays in this browser.${tester.ends_at_ms ? ` The link works until ${day(tester.ends_at_ms)}.` : ""}` : "";
50042
50117
  const reportHref = `/g/${game.slug}/report`;
@@ -50256,7 +50331,7 @@ ${isClosed ? scriptTags(GAME_PAGE_SCRIPT, GAME_EMBEDDED_SCRIPT) : scriptTags(
50256
50331
  )}`
50257
50332
  });
50258
50333
  response.headers.append("Vary", "Sec-Fetch-Dest, Sec-Fetch-Site");
50259
- if (!view.isShownInLists || tester) response.headers.set("X-Robots-Tag", "noindex");
50334
+ if (!view.isShownInLists || !view.searchEnginesEnabled || tester) response.headers.set("X-Robots-Tag", "noindex");
50260
50335
  return response;
50261
50336
  }
50262
50337
  var SECURITY_PAGE = `
@@ -51681,7 +51756,7 @@ var LINK = object({
51681
51756
  destination_url: url("The https address it opens; the game can add a path and query under it."),
51682
51757
  is_on_game_page: yes("The game page shows it too."),
51683
51758
  who_can_open: choice(
51684
- { everyone: "it's on a trusted store, payment or video site, so it opens for guests and under-18 accounts too", signed_in_adults: `it opens for signed-in adults; anyone else sees "This link can't be opened here."`, nobody: "its site is listed for malware or phishing (review_reason says so)" },
51759
+ { everyone: "it's on a trusted store, payment or video site, so it opens for guests and under-18 accounts too", signed_in_adults: `it opens for signed-in adults; anyone else sees "This link can't be opened here."`, waiting_for_standing: "a site off the trusted list that opens for signed-in adults once your account is established, or has a card that paid Zaisma or a Stripe account (get_access's standing); until then it opens for nobody", nobody: "its site is listed for malware or phishing (review_reason says so)" },
51685
51760
  "Who it opens for."
51686
51761
  ),
51687
51762
  review_reason: nullable(reviewReason2("Why it opens for nobody; null when it opens."))
@@ -51982,7 +52057,7 @@ var ACCESS = object({
51982
52057
  sign_in_app_access: choice(AREA_ACCESS_CHOICES.sign_in_app_access, "Sign-in apps; none for keys limited to some games."),
51983
52058
  webhook_access: choice(AREA_ACCESS_CHOICES.webhook_access, "Webhooks; none for keys limited to some games."),
51984
52059
  stripe_account_access: choice(AREA_ACCESS_CHOICES.stripe_account_access, "The developer's connected Stripe account for the buy sheet; none for keys limited to some games. read: the connection. write: also ask for a link to connect (the developer connects with their passkey on zaisma.com), turn Stripe Tax on or off, and disconnect."),
51985
- credit_access: choice(AREA_ACCESS_CHOICES.credit_access, "Hosting credit; none for keys limited to some games. read: balance, top-ups and spending. write: also start a top-up, refund one, and turn spending off or down (on or up needs the developer's passkey on zaisma.com). Also Zaisma Pro: read the plan; write: start, cancel or resume it.")
52060
+ credit_access: choice(AREA_ACCESS_CHOICES.credit_access, "Hosting credit; none for keys limited to some games. read: balance, top-ups and spending. write: also start a top-up, refund one, and turn spending off or down (on or up needs the developer's passkey on zaisma.com). Also the monthly credit plan: write starts it, changes its level (higher needs the passkey) and stops it. And Zaisma Pro: read the plan; write: start, cancel or resume it.")
51986
52061
  },
51987
52062
  "What the key may do in each area."
51988
52063
  ),
@@ -51997,6 +52072,22 @@ var NEXT = list(
51997
52072
  }),
51998
52073
  "The calls that usually follow; empty when nothing does."
51999
52074
  );
52075
+ var STANDING = object(
52076
+ {
52077
+ is_established_developer: yes("Whether the account is established: 14 days old, 20 different returning players on its games, and nothing ruled against it in 30 days. Zaisma's own accounts are established from the start."),
52078
+ outside_links_enabled: yes("Whether the games' links to sites outside the trusted list open (for signed-in adults): once established, or at once with a card that paid Zaisma or a Stripe account that can take payments. Until then those links answer who_can_open waiting_for_standing."),
52079
+ search_engines_enabled: yes("Whether search engines find the games (the sitemap, and no noindex on their pages): only once established. Zaisma's own lists show listed games from the first day either way."),
52080
+ returning_player_count: countOf("Different players who came back to your games on 2 days or more, each with an account at least 7 days old that played on 3 days, as the daily count last counted them. Your own account never counts."),
52081
+ minimum_returning_player_count: countOf("Returning players needed to be established: 20."),
52082
+ account_age_days: countOf("Whole days since the account was made."),
52083
+ minimum_account_age_days: countOf("Days old the account must be to be established: 14."),
52084
+ has_card_or_stripe_account: yes("Whether a card paid Zaisma (a top-up or Zaisma Pro) on this account, or its Stripe account can take payments: either opens links to other sites at once."),
52085
+ last_ruling_at: nullable(isoTime("When Zaisma last rejected one of your versions or took a game down; null when never.")),
52086
+ ruling_wait_ends_at: nullable(isoTime("While the 30 days after the last ruling run, when they end: until then links to other sites and search engines are closed. null when there's no wait.")),
52087
+ message_for_people: text2("What's open and what opens next, in a sentence or two, to show the developer.")
52088
+ },
52089
+ "What the account has earned, and what each power needs. Nobody else sees it."
52090
+ );
52000
52091
  var ERROR_FIELDS = object({
52001
52092
  code: text2("Stable, one problem per code; docs/DEVELOPER-API.md lists every code."),
52002
52093
  message: text2("What happened."),
@@ -52140,11 +52231,22 @@ var HOSTING_USE = object(
52140
52231
  },
52141
52232
  "This month (UTC) on the game's one hosting meter: the free allowance as a percentage, what used it, and what credit paid past it."
52142
52233
  );
52234
+ var CREDIT_PLAN = object(
52235
+ {
52236
+ refill_level_cents: countOf("What credit is filled back to on the 1st of every month (UTC), in cents (4000 is $40)."),
52237
+ plan_status: choice({ running: "Stripe is paid; the next 1st refills", payment_failing: "the card was declined, and Stripe is trying again over the next days" }),
52238
+ next_payment_at: isoTime("The next 1st, 00:00 UTC, when the refill is paid."),
52239
+ next_payment_cents: countOf("What the next 1st pays at today's balance: the level minus the balance, 0 when that's under $1 or the balance is at or over the level. Sales tax or VAT comes on top on Stripe's side."),
52240
+ started_at: isoTime()
52241
+ },
52242
+ "The monthly credit plan (start_credit_plan); null with none."
52243
+ );
52143
52244
  var CREDIT_ENTRY_KIND = choice({ top_up: "credit bought", hosting_charge: "a game's hosting past its free allowance, one entry per game per day", refund: "credit refunded to its card", written_off: "what a game used past its budget, your credit, or its free hosting while it had no budget, which Zaisma took on: never charged" });
52144
52245
  var CREDIT = object({
52145
52246
  credit: object({
52146
52247
  balance_cents: countOf("What's left to spend, in cents. Never below 0."),
52147
52248
  is_credit_low: yes("Whether the balance is under 20% of what there was right after the last top-up: add credit. Each game with a budget shows a credit_low alert meanwhile."),
52249
+ credit_plan: nullable(CREDIT_PLAN),
52148
52250
  top_ups: list(
52149
52251
  object({
52150
52252
  id: idOf("top_up"),
@@ -52162,17 +52264,16 @@ var CREDIT = object({
52162
52264
  )
52163
52265
  })
52164
52266
  });
52165
- var SPENDING_CHANGE_FIELDS = {
52166
- monthly_budget_cents: nullable(countOf("A game's monthly budget; null: no paid hosting."))
52167
- };
52267
+ var BUDGET_CHANGE = object({ monthly_budget_cents: nullable(countOf("A game's monthly budget; null: no paid hosting.")) }, "game_budget's");
52268
+ var PLAN_LEVEL_CHANGE = object({ refill_level_cents: nullable(countOf("The monthly credit plan's level; null: no plan.")) }, "credit_plan_refill_level's");
52168
52269
  var SPENDING_REQUEST = object({
52169
52270
  spending_request: object({
52170
52271
  id: idOf("spending_request"),
52171
- request_kind: choice({ game_budget: "a game's monthly budget" }),
52172
- game_slug: nullable(text2("The game; null once it's deleted.")),
52272
+ request_kind: choice({ game_budget: "a game's monthly budget", credit_plan_refill_level: "the monthly credit plan's level" }),
52273
+ game_slug: nullable(text2("The game; null once it's deleted, and for the credit plan's level.")),
52173
52274
  game_title: nullable(text2()),
52174
- requested_change: object(SPENDING_CHANGE_FIELDS, "The budget asked for."),
52175
- current_setting: object(SPENDING_CHANGE_FIELDS, "The same fields as they are now."),
52275
+ requested_change: { anyOf: [BUDGET_CHANGE, PLAN_LEVEL_CHANGE], description: "The change asked for: monthly_budget_cents for a game's budget, refill_level_cents for the credit plan's level." },
52276
+ current_setting: { anyOf: [BUDGET_CHANGE, PLAN_LEVEL_CHANGE], description: "The same field as it is now." },
52176
52277
  request_status: choice({ waiting: "the developer hasn't answered", approved: "approved: the change is made", declined: "the developer declined", expired: "a day passed without an answer" }),
52177
52278
  created_at: isoTime(),
52178
52279
  expires_at: isoTime("A day after it was asked."),
@@ -52212,6 +52313,12 @@ var PRO_CHECKOUT = object({
52212
52313
  expires_at: isoTime("When the page stops taking payment."),
52213
52314
  next_steps: NEXT
52214
52315
  });
52316
+ var CREDIT_PLAN_CHECKOUT = object({
52317
+ checkout_url: url("Stripe's page where the developer pays. Only a person can pay there."),
52318
+ expires_at: isoTime("When the page stops taking payment."),
52319
+ next_steps: NEXT
52320
+ });
52321
+ var CREDIT_CHANGED = object({ ...CREDIT.properties, next_steps: NEXT });
52215
52322
  var TOP_UP_CHECKOUT = object({
52216
52323
  checkout_url: url("Stripe's page where the developer pays. Only a person can pay there."),
52217
52324
  expires_at: isoTime("When the page stops taking payment."),
@@ -53374,6 +53481,11 @@ var BLOCKED_SCRIPT_LIST = object({
53374
53481
  "The last 7 days, most reported first, up to 100. Browser extensions' reports aren't kept."
53375
53482
  )
53376
53483
  });
53484
+ var ZAISMA_ACCOUNT_CHANGED = object({
53485
+ username: text2(),
53486
+ is_zaisma_account: yes("Whether it's one of Zaisma's own accounts now, established from the start (links to other sites and search engines open)."),
53487
+ next_steps: NEXT
53488
+ });
53377
53489
  var CAN_PUBLISH_CHANGED = object({
53378
53490
  username: text2(),
53379
53491
  can_publish: yes("Whether the account can make, upload and publish games now."),
@@ -53565,7 +53677,7 @@ var details = {
53565
53677
  required: ["link_name", "destination_url", "is_on_game_page"],
53566
53678
  additionalProperties: false
53567
53679
  },
53568
- description: `The game's registered links: the sites it opens with Zaisma.links.open(link_name), like its store, Patreon, Steam page or Discord, behind Zaisma's "You're leaving Zaisma" card over the game; the game can open no other sites. The whole list each time, up to 20; [] for none. The game can add a path and query under a link's address: Zaisma.links.open("store", { path: "/buy", queryParameters: { item: "sword" } }). Signed-in adults open any link; guests and under-18 accounts only links on trusted store, payment and video sites (itch.io, Steam, Patreon, Ko-fi, Gumroad, PayPal, Stripe payment links, hosted Bitcoin pages, YouTube and others), never chat apps or social networks like Discord or Instagram: get_game's who_can_open says which, and anyone a link doesn't open for sees "This link can't be opened here." (the game hears "stayed"). No link shorteners, IP addresses or user names in addresses. Every site is checked against Cloudflare's malware and phishing list when added and every day; a listed one opens for nobody, with a review_reason. Live at once, with no approval.`,
53680
+ description: `The game's registered links: the sites it opens with Zaisma.links.open(link_name), like its store, Patreon, Steam page or Discord, behind Zaisma's "You're leaving Zaisma" card over the game; the game can open no other sites. The whole list each time, up to 20; [] for none. The game can add a path and query under a link's address: Zaisma.links.open("store", { path: "/buy", queryParameters: { item: "sword" } }). Signed-in adults open any link once the account's standing opens links to other sites (established, or a card that paid Zaisma or a Stripe account: get_access's standing; until then who_can_open says waiting_for_standing and the link is kept); guests and under-18 accounts only links on trusted store, payment and video sites (itch.io, Steam, Patreon, Ko-fi, Gumroad, PayPal, Stripe payment links, hosted Bitcoin pages, YouTube and others), never chat apps or social networks like Discord or Instagram: get_game's who_can_open says which, and anyone a link doesn't open for sees "This link can't be opened here." (the game hears "stayed"). No link shorteners, IP addresses or user names in addresses. Every site is checked against Cloudflare's malware and phishing list when added and every day; a listed one opens for nobody, with a review_reason. Live at once, with no approval.`,
53569
53681
  examples: [[{ link_name: "store", destination_url: "https://kit.itch.io/sky-hopper", is_on_game_page: true }], [{ link_name: "patreon", destination_url: "https://www.patreon.com/kit", is_on_game_page: true }, { link_name: "discord", destination_url: "https://discord.gg/skyhopper", is_on_game_page: false }]]
53570
53682
  },
53571
53683
  glass_tint_color: {
@@ -54055,7 +54167,7 @@ Not: { "name": "Zaisma Login" } (name the app the way players know your site)`,
54055
54167
  {
54056
54168
  name: "get_credit",
54057
54169
  title: "My hosting credit",
54058
- description: "The developer's hosting credit: the balance (in cents, never in dollars), whether it's low (is_credit_low: under 20% of what there was after the last top-up), the last 30 days' top-ups with what each still refunds, each game's budget and what credit paid for it this month, and the last 90 days' entries. Every game gets free hosting each month on one meter (get_stats' hosting_use_this_month, a percentage); past it, only a game with a budget (update_game, monthly_budget_cents) draws from credit, never past its budget. Credit never expires and is never paid out.",
54170
+ description: "The developer's hosting credit: the balance (in cents, never in dollars), whether it's low (is_credit_low: under 20% of what there was after the last top-up), the monthly credit plan (credit_plan: its level, whether the card pays, and what the next 1st pays at today's balance; null with none), the last 30 days' top-ups with what each still refunds, each game's budget and what credit paid for it this month, and the last 90 days' entries. Every game gets free hosting each month on one meter (get_stats' hosting_use_this_month, a percentage); past it, only a game with a budget (update_game, monthly_budget_cents) draws from credit, never past its budget. Credit never expires and is never paid out.",
54059
54171
  inputSchema: object2({}),
54060
54172
  outputSchema: CREDIT,
54061
54173
  annotations: read,
@@ -54064,7 +54176,7 @@ Not: { "name": "Zaisma Login" } (name the app the way players know your site)`,
54064
54176
  {
54065
54177
  name: "get_spending_request",
54066
54178
  title: "A spending request's answer",
54067
- description: "Whether the developer has answered a raise in spending you asked for. When update_game (a game's first or higher budget) answers confirmation_needed, it also answers spending_request_id: give the developer confirm_url, where a sheet names the exact change and they approve with a passkey tap or decline. This shows request_status (waiting, approved, declined or expired after a day), the change asked for and what it is now. Approved means the change is already made.",
54179
+ description: "Whether the developer has answered a raise in spending you asked for. When update_game (a game's first or higher budget) or change_credit_plan (a higher level) answers confirmation_needed, it also answers spending_request_id: give the developer confirm_url, where a sheet names the exact change and they approve with a passkey tap or decline. This shows request_status (waiting, approved, declined or expired after a day), the change asked for and what it is now. Approved means the change is already made.",
54068
54180
  inputSchema: object2({ spending_request_id: { type: "string", pattern: "^spending_request_[a-z0-9]+$", description: "From confirmation_needed's spending_request_id, like spending_request_k2m9x4\u2026" } }, ["spending_request_id"]),
54069
54181
  outputSchema: SPENDING_REQUEST,
54070
54182
  annotations: read,
@@ -54082,6 +54194,38 @@ Not: { "amount_cents": 500 } (the least is 1000, $10)`,
54082
54194
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
54083
54195
  run: (toolArguments, call3) => call3("POST", "/credit/top-ups", pick(toolArguments, ["amount_cents"]))
54084
54196
  },
54197
+ {
54198
+ name: "start_credit_plan",
54199
+ title: "Start a monthly credit plan",
54200
+ description: `Start the developer's monthly credit plan: credit filled back to refill_level_cents on the 1st of every month (UTC), so games with a budget never run dry. Answers Stripe's page (checkout_url), where only the developer can pay; give them the link. The first payment fills credit to the level (at most $1,000; a higher level fills to it from the next 1st); each 1st pays only what brings the balance back to the level, nothing under $1 or when the balance is at or over it. Stripe sells every payment, with sales tax or VAT on top. Each payment is a top-up, refundable for 30 days with refund_top_up. One plan at a time: already_credit_plan when there is one.
54201
+
54202
+ Not: { "refill_level_cents": 40 } (that's 40 cents; cents, not dollars, so $40 is 4000)
54203
+ Not: { "refill_level_cents": 500 } (the least is 1000, $10)`,
54204
+ inputSchema: { ...object2({ refill_level_cents: { type: "integer", minimum: 1e3, maximum: 1e6, description: "The level in cents: 4000 is $40. From 1000 ($10) to 1000000 ($10,000)." } }, ["refill_level_cents"]), examples: [{ refill_level_cents: 4e3 }] },
54205
+ outputSchema: CREDIT_PLAN_CHECKOUT,
54206
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
54207
+ run: (toolArguments, call3) => call3("POST", "/credit/plan/checkout", pick(toolArguments, ["refill_level_cents"]))
54208
+ },
54209
+ {
54210
+ name: "change_credit_plan",
54211
+ title: "Change the monthly credit plan",
54212
+ description: `Set the monthly credit plan's level: what credit is filled back to on the 1st. A lower or equal level applies at once with any key that has credit_access write; a higher one spends more, so it answers confirmation_needed with confirm_url, where the developer approves it with their passkey, and spending_request_id for get_spending_request. Answers no_credit_plan with none (start_credit_plan starts one).
54213
+
54214
+ Not: { "refill_level_cents": 60 } (cents, not dollars, so $60 is 6000)`,
54215
+ inputSchema: { ...object2({ refill_level_cents: { type: "integer", minimum: 1e3, maximum: 1e6, description: "The new level in cents: 6000 is $60." } }, ["refill_level_cents"]), examples: [{ refill_level_cents: 6e3 }] },
54216
+ outputSchema: CREDIT_CHANGED,
54217
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54218
+ run: (toolArguments, call3) => call3("PATCH", "/credit/plan", pick(toolArguments, ["refill_level_cents"]))
54219
+ },
54220
+ {
54221
+ name: "stop_credit_plan",
54222
+ title: "Stop the monthly credit plan",
54223
+ description: "Stop the monthly credit plan at once: nothing is charged again, and credit already bought stays (each payment still refunds for 30 days with refund_top_up). It holds no prepaid month, so nothing is lost by stopping. Answers no_credit_plan with none. Ask the developer first.",
54224
+ inputSchema: object2({}),
54225
+ outputSchema: CREDIT_CHANGED,
54226
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
54227
+ run: (_, call3) => call3("POST", "/credit/plan/stop")
54228
+ },
54085
54229
  {
54086
54230
  name: "get_stripe_account",
54087
54231
  title: "My Stripe account",
@@ -54507,7 +54651,7 @@ Not: { "rating": "helpful" } (like)`,
54507
54651
  {
54508
54652
  name: "get_access",
54509
54653
  title: "What this key can do",
54510
- description: "What the API key you're signed in with may do: its access_level (read, upload, publish, full or custom), what that gives in each area, the games it works on (game_slugs, null for all) and when it ends (api_key_ends_at, null for never). A call the key doesn't reach answers key_not_allowed. Keys are made only by the developer on zaisma.com/dashboard/settings, never through the MCP. can_publish says whether the account can make, upload and publish games, which until the beta only accounts Zaisma adds can. banned_at and ban_scope say whether Zaisma banned the account: a banned developer can call only get_access, list_games and export_game. plan says whether the developer has Zaisma Pro (free or pro).",
54654
+ description: "What the API key you're signed in with may do: its access_level (read, upload, publish, full or custom), what that gives in each area, the games it works on (game_slugs, null for all) and when it ends (api_key_ends_at, null for never). A call the key doesn't reach answers key_not_allowed. Keys are made only by the developer on zaisma.com/dashboard/settings, never through the MCP. can_publish says whether the account can make, upload and publish games, which until the beta only accounts Zaisma adds can. banned_at and ban_scope say whether Zaisma banned the account: a banned developer can call only get_access, list_games and export_game. plan says whether the developer has Zaisma Pro (free or pro). standing says what the account has earned and what each power needs: links to sites outside the trusted list open once it's established (14 days old, 20 different returning players, nothing ruled against it in 30 days) or at once with a card that paid Zaisma or a Stripe account, and search engines only once it's established; tell the developer standing.message_for_people when they ask why a link doesn't open or their game isn't on search engines.",
54511
54655
  inputSchema: object2({}),
54512
54656
  outputSchema: object({
54513
54657
  access: ACCESS,
@@ -54515,7 +54659,9 @@ Not: { "rating": "helpful" } (like)`,
54515
54659
  banned_at: nullable(isoTime("When Zaisma banned this account; null when it isn't banned.")),
54516
54660
  ban_scope: nullable(choice({ developer: "its games are down, and only get_access, list_games and export_game work; playing still works", whole_account: "the account is closed" }, "How far the ban goes; null when it isn't banned.")),
54517
54661
  credit_balance_cents: nullable(countOf("The developer's hosting credit, in cents; null when the key can't read it (credit_access none, or a key limited to some games).")),
54518
- plan: choice({ free: "the free daily limits", pro: "Zaisma Pro: the higher daily limits" }, "The developer's plan; get_pro has its limits.")
54662
+ plan: choice({ free: "the free daily limits", pro: "Zaisma Pro: the higher daily limits" }, "The developer's plan; get_pro has its limits."),
54663
+ standing: STANDING,
54664
+ next_steps: NEXT
54519
54665
  }),
54520
54666
  annotations: read,
54521
54667
  run: (_, call3) => call3("GET", "/access")
@@ -55359,7 +55505,7 @@ async function precedentAction(env2, path, form, actor) {
55359
55505
  var redirect = () => new Response(null, { status: 303, headers: { Location: "/admin/precedents" } });
55360
55506
 
55361
55507
  // ../../apps/site/src/admin-mcp.ts
55362
- var DAY_MS9 = 864e5;
55508
+ var DAY_MS10 = 864e5;
55363
55509
  var read2 = { readOnlyHint: true, openWorldHint: false };
55364
55510
  var change = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
55365
55511
  var object3 = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -55579,7 +55725,7 @@ var ADMIN_TOOLS = [
55579
55725
  run: async (_, { env: env2 }) => {
55580
55726
  const { results } = await env2.DB.prepare(
55581
55727
  `${QUEUE_SELECT} WHERE reports.report_status != 'open' AND reports.closed_at_ms = reports.triaged_at_ms AND reports.closed_at_ms > ? AND reports.severity_index > 0 ORDER BY reports.closed_at_ms DESC LIMIT 100`
55582
- ).bind(Date.now() - 7 * DAY_MS9).all();
55728
+ ).bind(Date.now() - 7 * DAY_MS10).all();
55583
55729
  return {
55584
55730
  period_duration_days: 7,
55585
55731
  reports: results.map((report) => {
@@ -56187,6 +56333,29 @@ Not: { "decision": "block", "why": "\u2026" } for every country (a game breaking
56187
56333
  return { username: account.username, can_publish: canPublish, publishing_accounts: publishingAccountRows(await publishingAccounts(env2.DB)), next_steps: [] };
56188
56334
  }
56189
56335
  },
56336
+ {
56337
+ name: "set_zaisma_account",
56338
+ title: "Mark one of Zaisma's own accounts",
56339
+ description: "Mark an account as one of Zaisma's own (is_zaisma_account true), like the seed games' developers, by its username, or unmark it (false). Zaisma's own accounts are established from the start, so their games' links to other sites and search engines open before any players come back; a ruling or a ban still closes them. Unmarking it leaves it to the daily count. Ask the admin first.",
56340
+ inputSchema: object3(
56341
+ {
56342
+ username: usernameInput,
56343
+ is_zaisma_account: { type: "boolean", description: "true marks it as Zaisma's own; false unmarks it." }
56344
+ },
56345
+ ["username", "is_zaisma_account"]
56346
+ ),
56347
+ outputSchema: ZAISMA_ACCOUNT_CHANGED,
56348
+ annotations: change,
56349
+ run: async (toolArguments, { env: env2, actor }) => {
56350
+ const account = await accountNamed(env2, toolArguments.username);
56351
+ const isZaismaAccount = toolArguments.is_zaisma_account === true;
56352
+ const owner = await personOwner(env2.DB, account.id);
56353
+ const rulingWaitStartedAfterMs = Date.now() - STANDING_LIMITS.rulingWaitDurationDays * 864e5;
56354
+ await env2.DB.prepare("UPDATE owners SET is_zaisma_account = ?1, is_established = CASE WHEN ?1 = 1 AND (last_ruling_at_ms IS NULL OR last_ruling_at_ms <= ?2) THEN 1 ELSE 0 END, standing_checked_at_ms = NULL WHERE id = ?3").bind(isZaismaAccount ? 1 : 0, rulingWaitStartedAfterMs, owner.id).run();
56355
+ await audit(env2.DB, null, isZaismaAccount ? "zaisma-account-marked" : "zaisma-account-unmarked", `@${account.username}`, actor);
56356
+ return { username: account.username, is_zaisma_account: isZaismaAccount, next_steps: [] };
56357
+ }
56358
+ },
56190
56359
  {
56191
56360
  name: "ban_account",
56192
56361
  title: "Ban an account",
@@ -56456,6 +56625,7 @@ function countryBlockSection(game) {
56456
56625
  async function takeDownGame(env2, gameId, contentRule, reason, actor) {
56457
56626
  const removedReason = decidedReason("taken_down", contentRule, reason);
56458
56627
  await setReviewStatus(env2.DB, gameId, "removed", removedReason);
56628
+ await noteRulingOnGame(env2, gameId);
56459
56629
  await recordRestoreEvent(env2, { event_kind: "game_removed", at_ms: Date.now(), game_id: gameId, review_reason: removedReason });
56460
56630
  await closeReports(env2.DB, gameId, "actioned");
56461
56631
  await audit(env2.DB, gameId, "takedown", reason, actor, removedReason);
@@ -56512,6 +56682,7 @@ async function adminForm(env2, path, form, actor) {
56512
56682
  const rejectedReason = decidedReason("rejected_by_zaisma", pickedRule(form), reason);
56513
56683
  await saveScan(env2.DB, version.id, "rejected", null);
56514
56684
  await env2.DB.prepare("UPDATE versions SET review_reason = ? WHERE id = ?").bind(JSON.stringify(rejectedReason), version.id).run();
56685
+ await noteRulingOnGame(env2, version.game_id);
56515
56686
  if (version.report_held_at_ms) await closeReports(env2.DB, version.game_id, "actioned");
56516
56687
  await audit(env2.DB, version.game_id, "rejected", reason ? `${version.id}: ${reason}` : version.id, actor, rejectedReason);
56517
56688
  }
@@ -57326,6 +57497,9 @@ async function playerPage(input) {
57326
57497
  updates: [],
57327
57498
  otherGames: [],
57328
57499
  liveRooms: null,
57500
+ // The developer's own computer: their links open as an established developer's would, and no search engine is near it.
57501
+ searchEnginesEnabled: false,
57502
+ outsideLinksEnabled: true,
57329
57503
  checkedPlayerCount: 0,
57330
57504
  hasThreads: false,
57331
57505
  purchaseLabel: { categories: input.soldCategories, has_own_payments: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",