juicyjuicy 0.10.0 → 0.11.1
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/dist/index.js +357 -33
- package/dist/skill/SKILL.md +19 -17
- package/dist/skill/reference/languages.md +1 -1
- package/dist/skill/reference/manifest.md +2 -1
- package/dist/skill/reference/multiplayer.md +60 -19
- package/dist/skill/reference/troubleshooting.md +1 -1
- package/dist/skill/reference/update-all-games.md +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19807,7 +19807,7 @@ function date4(params) {
|
|
|
19807
19807
|
}
|
|
19808
19808
|
|
|
19809
19809
|
// ../shared/src/cli.ts
|
|
19810
|
-
var CLI_RELEASE = "0.
|
|
19810
|
+
var CLI_RELEASE = "0.11.1";
|
|
19811
19811
|
function cliCommand(args) {
|
|
19812
19812
|
return `npx -y juicyjuicy@${CLI_RELEASE} ${args}`;
|
|
19813
19813
|
}
|
|
@@ -19909,6 +19909,10 @@ var RATE_LIMITS = {
|
|
|
19909
19909
|
};
|
|
19910
19910
|
var THUMBNAIL_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
|
|
19911
19911
|
|
|
19912
|
+
// ../shared/src/ids.ts
|
|
19913
|
+
var PUBLIC_ID_LENGTH = 10;
|
|
19914
|
+
var PUBLIC_ID_PATTERN = new RegExp(`^[a-z0-9]{${PUBLIC_ID_LENGTH}}$`);
|
|
19915
|
+
|
|
19912
19916
|
// ../shared/src/paths.ts
|
|
19913
19917
|
var IGNORED_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "__MACOSX"]);
|
|
19914
19918
|
var IGNORED_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
@@ -20053,6 +20057,12 @@ var gameFieldSchemas = {
|
|
|
20053
20057
|
var manifestSchema = external_exports.strictObject({
|
|
20054
20058
|
$schema: external_exports.string().optional(),
|
|
20055
20059
|
slug: slugSchema,
|
|
20060
|
+
/**
|
|
20061
|
+
* The game this folder publishes to (its public id, D65), written by `publish` and `pull`. A
|
|
20062
|
+
* publish whose `slug` names another game or a new one is refused (SLUG_CHANGED), so changing
|
|
20063
|
+
* the slug, or copying the folder for a new game, never quietly makes a second game.
|
|
20064
|
+
*/
|
|
20065
|
+
id: external_exports.string().regex(PUBLIC_ID_PATTERN, "id is written by juicyjuicy publish: leave it as it is").optional(),
|
|
20056
20066
|
title: gameFieldSchemas.title,
|
|
20057
20067
|
description: gameFieldSchemas.description.default(""),
|
|
20058
20068
|
buildDir: external_exports.string().default(".").transform((dir) => normalizePath(dir).replace(/\/+$/, "") || ".").refine(
|
|
@@ -22311,7 +22321,7 @@ function checkGameFiles(files, entry) {
|
|
|
22311
22321
|
}
|
|
22312
22322
|
return findings;
|
|
22313
22323
|
}
|
|
22314
|
-
var KEY_MESSAGE = (what) => (
|
|
22324
|
+
var KEY_MESSAGE = (what) => (found2) => `contains what looks like ${what} (${found2}). Every player can read a game's files: remove it, and revoke the key`;
|
|
22315
22325
|
var CONTENT_RULES = [
|
|
22316
22326
|
{
|
|
22317
22327
|
rule: "secret-key",
|
|
@@ -22360,13 +22370,13 @@ var CONTENT_RULES = [
|
|
|
22360
22370
|
rule: "crypto-miner",
|
|
22361
22371
|
severity: "error",
|
|
22362
22372
|
pattern: /stratum\+(?:tcp|ssl|tls):\/\/|\bCoinHive\.(?:Anonymous|User|Token)\b|\bcoin-?hive\.com\b|\bauthedmine\.com\b|\bcrypto-loot\.(?:com|org)\b|\bCRLT\.Anonymous\b|\bcoinimp\.com\b|\bwebminepool\.com\b|\bminero\.cc\b|\bdeepMiner\.(?:Anonymous|Init)\b|\bcryptonight(?:_wasm|\.wasm)\b/,
|
|
22363
|
-
message: (
|
|
22373
|
+
message: (found2) => `contains crypto-mining code (${found2}). Mining in players' browsers isn't allowed`
|
|
22364
22374
|
},
|
|
22365
22375
|
{
|
|
22366
22376
|
rule: "google-api-key",
|
|
22367
22377
|
severity: "warning",
|
|
22368
22378
|
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
22369
|
-
message: (
|
|
22379
|
+
message: (found2) => `contains a Google API key (${found2}). Firebase web keys are public by design, but make sure the project's security rules and the key's restrictions don't let players do more than the game needs`
|
|
22370
22380
|
},
|
|
22371
22381
|
{
|
|
22372
22382
|
rule: "threads",
|
|
@@ -22390,13 +22400,13 @@ var CONTENT_RULES = [
|
|
|
22390
22400
|
rule: "insecure-url",
|
|
22391
22401
|
severity: "warning",
|
|
22392
22402
|
pattern: /\b(?:src|href)\s*=\s*["']http:\/\/|\burl\(\s*["']?http:\/\/|\b(?:fetch|import)\(\s*["'`]http:\/\/|["'`]ws:\/\//,
|
|
22393
|
-
message: (
|
|
22403
|
+
message: (found2) => `loads something over plain http or ws (${found2}); browsers block that on an https page. Use https:// or wss://`
|
|
22394
22404
|
},
|
|
22395
22405
|
{
|
|
22396
22406
|
rule: "localhost-url",
|
|
22397
22407
|
severity: "warning",
|
|
22398
22408
|
pattern: /["'`(](?:https?|wss?):\/\/(?:localhost|127\.0\.0\.1)(?=[:/"'`)])/,
|
|
22399
|
-
message: (
|
|
22409
|
+
message: (found2) => `points at localhost (${found2}), which is the player's own computer, not your dev server`
|
|
22400
22410
|
},
|
|
22401
22411
|
{
|
|
22402
22412
|
rule: "password-input",
|
|
@@ -22405,8 +22415,8 @@ var CONTENT_RULES = [
|
|
|
22405
22415
|
message: () => "has a password field. A game should never ask for a password: players can't tell a game's form from a phishing page"
|
|
22406
22416
|
}
|
|
22407
22417
|
];
|
|
22408
|
-
function shorten(
|
|
22409
|
-
const text2 =
|
|
22418
|
+
function shorten(found2, rule) {
|
|
22419
|
+
const text2 = found2.replace(/\s+/g, " ");
|
|
22410
22420
|
if (rule === "secret-key") return `${text2.slice(0, 10)}\u2026`;
|
|
22411
22421
|
return text2.length > 60 ? `${text2.slice(0, 57)}\u2026` : text2;
|
|
22412
22422
|
}
|
|
@@ -22507,20 +22517,20 @@ async function checkGame({
|
|
|
22507
22517
|
for (const id of markersIn(text2, scanned)) detected.add(id);
|
|
22508
22518
|
});
|
|
22509
22519
|
findings.push(...perFile.flat());
|
|
22510
|
-
const
|
|
22520
|
+
const found2 = [...detected].sort();
|
|
22511
22521
|
if (declaredFeatures !== void 0) {
|
|
22512
22522
|
if (declaredFeatures) {
|
|
22513
|
-
findings.push(...featureFindings({ declared: declaredFeatures, detected:
|
|
22523
|
+
findings.push(...featureFindings({ declared: declaredFeatures, detected: found2, complete }));
|
|
22514
22524
|
}
|
|
22515
22525
|
findings.push(
|
|
22516
22526
|
...requirementFindings(
|
|
22517
22527
|
requirements,
|
|
22518
|
-
{ declared: declaredFeatures, detected:
|
|
22528
|
+
{ declared: declaredFeatures, detected: found2, complete, languages, client },
|
|
22519
22529
|
now
|
|
22520
22530
|
)
|
|
22521
22531
|
);
|
|
22522
22532
|
}
|
|
22523
|
-
return { findings, scriptHosts: [...hosts].sort(), complete, detected:
|
|
22533
|
+
return { findings, scriptHosts: [...hosts].sort(), complete, detected: found2 };
|
|
22524
22534
|
}
|
|
22525
22535
|
|
|
22526
22536
|
// ../shared/src/device.ts
|
|
@@ -22539,6 +22549,239 @@ var deviceCodeRequestSchema = external_exports.object({
|
|
|
22539
22549
|
});
|
|
22540
22550
|
var deviceTokenRequestSchema = external_exports.object({ deviceCode: external_exports.string().min(1).max(200) });
|
|
22541
22551
|
|
|
22552
|
+
// ../shared/src/english.ts
|
|
22553
|
+
var LANGUAGE_NAMES = {
|
|
22554
|
+
pl: "Polish",
|
|
22555
|
+
de: "German",
|
|
22556
|
+
fr: "French",
|
|
22557
|
+
es: "Spanish",
|
|
22558
|
+
it: "Italian"
|
|
22559
|
+
};
|
|
22560
|
+
var COMMON_WORDS = {
|
|
22561
|
+
pl: {
|
|
22562
|
+
words: [
|
|
22563
|
+
"i",
|
|
22564
|
+
"w",
|
|
22565
|
+
"z",
|
|
22566
|
+
"na",
|
|
22567
|
+
"si\u0119",
|
|
22568
|
+
"nie",
|
|
22569
|
+
"jest",
|
|
22570
|
+
"dla",
|
|
22571
|
+
"\u017Ce",
|
|
22572
|
+
"jak",
|
|
22573
|
+
"po",
|
|
22574
|
+
"od",
|
|
22575
|
+
"ze",
|
|
22576
|
+
"oraz",
|
|
22577
|
+
"przez",
|
|
22578
|
+
"aby",
|
|
22579
|
+
"gdzie",
|
|
22580
|
+
"gra",
|
|
22581
|
+
"grze",
|
|
22582
|
+
"tw\xF3j",
|
|
22583
|
+
"swoje",
|
|
22584
|
+
"mo\u017Cesz",
|
|
22585
|
+
"musisz",
|
|
22586
|
+
"ka\u017Cdy"
|
|
22587
|
+
],
|
|
22588
|
+
titleWords: [
|
|
22589
|
+
"na",
|
|
22590
|
+
"si\u0119",
|
|
22591
|
+
"nie",
|
|
22592
|
+
"jest",
|
|
22593
|
+
"dla",
|
|
22594
|
+
"oraz",
|
|
22595
|
+
"przez",
|
|
22596
|
+
"pod",
|
|
22597
|
+
"nad",
|
|
22598
|
+
"bez",
|
|
22599
|
+
"ze",
|
|
22600
|
+
"od",
|
|
22601
|
+
"czy",
|
|
22602
|
+
"kt\xF3ry",
|
|
22603
|
+
"kt\xF3ra"
|
|
22604
|
+
]
|
|
22605
|
+
},
|
|
22606
|
+
de: {
|
|
22607
|
+
words: [
|
|
22608
|
+
"der",
|
|
22609
|
+
"die",
|
|
22610
|
+
"das",
|
|
22611
|
+
"und",
|
|
22612
|
+
"ist",
|
|
22613
|
+
"mit",
|
|
22614
|
+
"ein",
|
|
22615
|
+
"eine",
|
|
22616
|
+
"einen",
|
|
22617
|
+
"f\xFCr",
|
|
22618
|
+
"nicht",
|
|
22619
|
+
"auf",
|
|
22620
|
+
"den",
|
|
22621
|
+
"dem",
|
|
22622
|
+
"zu",
|
|
22623
|
+
"von",
|
|
22624
|
+
"sich",
|
|
22625
|
+
"du",
|
|
22626
|
+
"dein",
|
|
22627
|
+
"deine",
|
|
22628
|
+
"wie",
|
|
22629
|
+
"spiel",
|
|
22630
|
+
"kannst",
|
|
22631
|
+
"musst"
|
|
22632
|
+
],
|
|
22633
|
+
titleWords: [
|
|
22634
|
+
"der",
|
|
22635
|
+
"das",
|
|
22636
|
+
"und",
|
|
22637
|
+
"ist",
|
|
22638
|
+
"mit",
|
|
22639
|
+
"ein",
|
|
22640
|
+
"eine",
|
|
22641
|
+
"einen",
|
|
22642
|
+
"f\xFCr",
|
|
22643
|
+
"nicht",
|
|
22644
|
+
"auf",
|
|
22645
|
+
"dem",
|
|
22646
|
+
"den",
|
|
22647
|
+
"zum",
|
|
22648
|
+
"zur"
|
|
22649
|
+
]
|
|
22650
|
+
},
|
|
22651
|
+
fr: {
|
|
22652
|
+
words: [
|
|
22653
|
+
"le",
|
|
22654
|
+
"la",
|
|
22655
|
+
"les",
|
|
22656
|
+
"et",
|
|
22657
|
+
"un",
|
|
22658
|
+
"une",
|
|
22659
|
+
"des",
|
|
22660
|
+
"du",
|
|
22661
|
+
"est",
|
|
22662
|
+
"avec",
|
|
22663
|
+
"pour",
|
|
22664
|
+
"dans",
|
|
22665
|
+
"sur",
|
|
22666
|
+
"pas",
|
|
22667
|
+
"tu",
|
|
22668
|
+
"ton",
|
|
22669
|
+
"ta",
|
|
22670
|
+
"tes",
|
|
22671
|
+
"vous",
|
|
22672
|
+
"votre",
|
|
22673
|
+
"jeu",
|
|
22674
|
+
"qui",
|
|
22675
|
+
"que"
|
|
22676
|
+
],
|
|
22677
|
+
titleWords: ["les", "et", "une", "des", "du", "avec", "pour", "dans", "sur", "au", "aux"]
|
|
22678
|
+
},
|
|
22679
|
+
es: {
|
|
22680
|
+
words: [
|
|
22681
|
+
"el",
|
|
22682
|
+
"la",
|
|
22683
|
+
"los",
|
|
22684
|
+
"las",
|
|
22685
|
+
"y",
|
|
22686
|
+
"un",
|
|
22687
|
+
"una",
|
|
22688
|
+
"con",
|
|
22689
|
+
"para",
|
|
22690
|
+
"por",
|
|
22691
|
+
"es",
|
|
22692
|
+
"del",
|
|
22693
|
+
"en",
|
|
22694
|
+
"que",
|
|
22695
|
+
"tu",
|
|
22696
|
+
"tus",
|
|
22697
|
+
"juego",
|
|
22698
|
+
"puedes",
|
|
22699
|
+
"debes",
|
|
22700
|
+
"cada"
|
|
22701
|
+
],
|
|
22702
|
+
titleWords: ["una", "para", "por", "y", "las", "con"]
|
|
22703
|
+
},
|
|
22704
|
+
it: {
|
|
22705
|
+
words: [
|
|
22706
|
+
"il",
|
|
22707
|
+
"lo",
|
|
22708
|
+
"gli",
|
|
22709
|
+
"le",
|
|
22710
|
+
"e",
|
|
22711
|
+
"di",
|
|
22712
|
+
"della",
|
|
22713
|
+
"delle",
|
|
22714
|
+
"del",
|
|
22715
|
+
"un",
|
|
22716
|
+
"una",
|
|
22717
|
+
"con",
|
|
22718
|
+
"per",
|
|
22719
|
+
"che",
|
|
22720
|
+
"\xE8",
|
|
22721
|
+
"nel",
|
|
22722
|
+
"nella",
|
|
22723
|
+
"tuo",
|
|
22724
|
+
"tua",
|
|
22725
|
+
"gioco",
|
|
22726
|
+
"puoi",
|
|
22727
|
+
"devi"
|
|
22728
|
+
],
|
|
22729
|
+
titleWords: ["gli", "della", "delle", "dello", "nel", "nella", "che", "per"]
|
|
22730
|
+
}
|
|
22731
|
+
};
|
|
22732
|
+
var ENGLISH_WORDS = new Set(
|
|
22733
|
+
"the a an and or of to in on at for with you your is are it its this that be by from as can get each every while before after into out up all".split(
|
|
22734
|
+
" "
|
|
22735
|
+
)
|
|
22736
|
+
);
|
|
22737
|
+
var LANGUAGE_LETTERS = [
|
|
22738
|
+
["pl", /[ąćęłńśźż]/giu],
|
|
22739
|
+
["de", /[äöüß]/giu],
|
|
22740
|
+
["es", /[ñ¿¡]/giu]
|
|
22741
|
+
];
|
|
22742
|
+
var WORD = new RegExp("\\p{L}+", "gu");
|
|
22743
|
+
function words(text2) {
|
|
22744
|
+
return text2.toLowerCase().match(WORD) ?? [];
|
|
22745
|
+
}
|
|
22746
|
+
function mostCommon(tokens, list2) {
|
|
22747
|
+
let best = null;
|
|
22748
|
+
for (const language of Object.keys(COMMON_WORDS)) {
|
|
22749
|
+
const set2 = new Set(COMMON_WORDS[language][list2]);
|
|
22750
|
+
const count = tokens.filter((token) => set2.has(token)).length;
|
|
22751
|
+
if (count > 0 && (!best || count > best.count)) best = { language, count };
|
|
22752
|
+
}
|
|
22753
|
+
return best;
|
|
22754
|
+
}
|
|
22755
|
+
function letters(text2) {
|
|
22756
|
+
let best = null;
|
|
22757
|
+
for (const [language, pattern] of LANGUAGE_LETTERS) {
|
|
22758
|
+
const count = text2.match(pattern)?.length ?? 0;
|
|
22759
|
+
if (count > 0 && (!best || count > best.count)) best = { language, count };
|
|
22760
|
+
}
|
|
22761
|
+
return best;
|
|
22762
|
+
}
|
|
22763
|
+
var found = (language) => ({
|
|
22764
|
+
language,
|
|
22765
|
+
languageName: LANGUAGE_NAMES[language]
|
|
22766
|
+
});
|
|
22767
|
+
function descriptionNotEnglish(description) {
|
|
22768
|
+
const tokens = words(description);
|
|
22769
|
+
const english = tokens.filter((token) => ENGLISH_WORDS.has(token)).length;
|
|
22770
|
+
const common = mostCommon(tokens, "words");
|
|
22771
|
+
if (common && common.count >= 2 && common.count > english) return found(common.language);
|
|
22772
|
+
const letter = letters(description);
|
|
22773
|
+
if (letter && letter.count >= 3 && english < 2) return found(letter.language);
|
|
22774
|
+
return null;
|
|
22775
|
+
}
|
|
22776
|
+
function titleNotEnglish(title) {
|
|
22777
|
+
const common = mostCommon(words(title), "titleWords");
|
|
22778
|
+
return common ? found(common.language) : null;
|
|
22779
|
+
}
|
|
22780
|
+
function titleLetters(title) {
|
|
22781
|
+
const letter = letters(title);
|
|
22782
|
+
return letter ? found(letter.language) : null;
|
|
22783
|
+
}
|
|
22784
|
+
|
|
22542
22785
|
// ../shared/src/errors.ts
|
|
22543
22786
|
var ERROR_STATUS = {
|
|
22544
22787
|
UNAUTHORIZED: 401,
|
|
@@ -22555,6 +22798,11 @@ var ERROR_STATUS = {
|
|
|
22555
22798
|
UPLOAD_INCOMPLETE: 409,
|
|
22556
22799
|
VERSION_NOT_UPLOADING: 409,
|
|
22557
22800
|
NOT_REMIXABLE: 403,
|
|
22801
|
+
/**
|
|
22802
|
+
* juicy.json's `id` is one of the creator's games, but its `slug` names another game or a new one
|
|
22803
|
+
* (D65): the slug was changed, or the folder was copied. Nothing was created.
|
|
22804
|
+
*/
|
|
22805
|
+
SLUG_CHANGED: 409,
|
|
22558
22806
|
/** A game check (SECURITY-PLAN S2) found something that can't be published: a key, a program, a miner. */
|
|
22559
22807
|
UNSAFE_CONTENT: 400,
|
|
22560
22808
|
RATE_LIMITED: 429,
|
|
@@ -22578,10 +22826,6 @@ function isApiErrorBody(value) {
|
|
|
22578
22826
|
return typeof code === "string" && code in ERROR_STATUS;
|
|
22579
22827
|
}
|
|
22580
22828
|
|
|
22581
|
-
// ../shared/src/ids.ts
|
|
22582
|
-
var PUBLIC_ID_LENGTH = 10;
|
|
22583
|
-
var PUBLIC_ID_PATTERN = new RegExp(`^[a-z0-9]{${PUBLIC_ID_LENGTH}}$`);
|
|
22584
|
-
|
|
22585
22829
|
// ../shared/src/license.ts
|
|
22586
22830
|
var REMIX_LICENSE_NAME = "JuicyJuicy remix license";
|
|
22587
22831
|
var REMIX_LICENSE = "Members may download this game's files and publish a changed version of it on JuicyJuicy, credited to the original. It doesn't allow publishing the game or a remix of it anywhere else, or passing the files on. Turning Remixable off stops new remix downloads; remixes already published stay up.";
|
|
@@ -22589,6 +22833,7 @@ var REMIX_LICENSE = "Members may download this game's files and publish a change
|
|
|
22589
22833
|
// ../shared/src/listing.ts
|
|
22590
22834
|
var GAME_SORTS = [
|
|
22591
22835
|
"newest",
|
|
22836
|
+
"updated",
|
|
22592
22837
|
"oldest",
|
|
22593
22838
|
"trending",
|
|
22594
22839
|
"upvoted",
|
|
@@ -22605,12 +22850,25 @@ var DEFAULT_GAME_LIST_QUERY = {
|
|
|
22605
22850
|
};
|
|
22606
22851
|
var MAX_GAME_LIST_PAGE = 1e3;
|
|
22607
22852
|
var MAX_GAME_SEARCH_LENGTH = 100;
|
|
22853
|
+
var MAX_GAME_LIST_TAGS = 5;
|
|
22608
22854
|
var lowercase2 = (value) => typeof value === "string" ? value.toLowerCase() : value;
|
|
22609
22855
|
var paramSchemas = {
|
|
22610
22856
|
sort: external_exports.preprocess(lowercase2, external_exports.enum(GAME_SORTS)),
|
|
22611
22857
|
period: external_exports.preprocess(lowercase2, external_exports.enum(GAME_PERIODS)),
|
|
22612
22858
|
engine: external_exports.preprocess(lowercase2, external_exports.enum(ENGINES)),
|
|
22613
|
-
tag
|
|
22859
|
+
// One tag, or several split by commas. Its issues stay on `tag` itself, not on one piece of it.
|
|
22860
|
+
tag: external_exports.preprocess(
|
|
22861
|
+
lowercase2,
|
|
22862
|
+
external_exports.string().transform((value) => [...new Set(value.split(",").map((tag) => tag.trim()))]).superRefine((tags, ctx) => {
|
|
22863
|
+
if (tags.length > MAX_GAME_LIST_TAGS) {
|
|
22864
|
+
ctx.addIssue({ code: "custom", message: `use at most ${MAX_GAME_LIST_TAGS} tags` });
|
|
22865
|
+
}
|
|
22866
|
+
for (const tag of tags) {
|
|
22867
|
+
const issue2 = gameFieldSchemas.tags.element.safeParse(tag).error?.issues[0];
|
|
22868
|
+
if (issue2) ctx.addIssue({ code: "custom", message: issue2.message });
|
|
22869
|
+
}
|
|
22870
|
+
})
|
|
22871
|
+
),
|
|
22614
22872
|
lang: languageSchema,
|
|
22615
22873
|
q: external_exports.string().max(MAX_GAME_SEARCH_LENGTH),
|
|
22616
22874
|
remixes: external_exports.enum(["0", "1"]),
|
|
@@ -22634,7 +22892,11 @@ var gameListRequestSchema = external_exports.object({
|
|
|
22634
22892
|
highscores: paramSchemas.feature.optional(),
|
|
22635
22893
|
list: paramSchemas.list.optional(),
|
|
22636
22894
|
page: paramSchemas.page.default(DEFAULT_GAME_LIST_QUERY.page)
|
|
22637
|
-
}).transform(({ remixes, ...rest }) => ({
|
|
22895
|
+
}).transform(({ remixes, tag, ...rest }) => ({
|
|
22896
|
+
...rest,
|
|
22897
|
+
...tag ? { tags: tag } : {},
|
|
22898
|
+
remixes: remixFilter(remixes)
|
|
22899
|
+
}));
|
|
22638
22900
|
|
|
22639
22901
|
// ../shared/src/realtime.ts
|
|
22640
22902
|
var ROOM_LIMITS = {
|
|
@@ -26194,7 +26456,7 @@ var program = new Command();
|
|
|
26194
26456
|
// package.json
|
|
26195
26457
|
var package_default = {
|
|
26196
26458
|
name: "juicyjuicy",
|
|
26197
|
-
version: "0.
|
|
26459
|
+
version: "0.11.1",
|
|
26198
26460
|
description: "Publish AI-made browser games to juicyjuicy.games",
|
|
26199
26461
|
type: "module",
|
|
26200
26462
|
license: "MIT",
|
|
@@ -26457,18 +26719,18 @@ var wrapWord = (rows, word, columns) => {
|
|
|
26457
26719
|
}
|
|
26458
26720
|
};
|
|
26459
26721
|
var stringVisibleTrimSpacesRight = (string4) => {
|
|
26460
|
-
const
|
|
26461
|
-
let last =
|
|
26722
|
+
const words2 = string4.split(" ");
|
|
26723
|
+
let last = words2.length;
|
|
26462
26724
|
while (last) {
|
|
26463
|
-
if (dist_default2(
|
|
26725
|
+
if (dist_default2(words2[last - 1])) {
|
|
26464
26726
|
break;
|
|
26465
26727
|
}
|
|
26466
26728
|
last--;
|
|
26467
26729
|
}
|
|
26468
|
-
if (last ===
|
|
26730
|
+
if (last === words2.length) {
|
|
26469
26731
|
return string4;
|
|
26470
26732
|
}
|
|
26471
|
-
return
|
|
26733
|
+
return words2.slice(0, last).join(" ") + words2.slice(last).join("");
|
|
26472
26734
|
};
|
|
26473
26735
|
var exec = (string4, columns, options = {}) => {
|
|
26474
26736
|
if (options.trim !== false && string4.trim() === "") {
|
|
@@ -26477,11 +26739,11 @@ var exec = (string4, columns, options = {}) => {
|
|
|
26477
26739
|
let returnValue = "";
|
|
26478
26740
|
let escapeCode;
|
|
26479
26741
|
let escapeUrl;
|
|
26480
|
-
const
|
|
26742
|
+
const words2 = string4.split(" ");
|
|
26481
26743
|
let rows = [""];
|
|
26482
26744
|
let rowLength = 0;
|
|
26483
|
-
for (let index = 0; index <
|
|
26484
|
-
const word =
|
|
26745
|
+
for (let index = 0; index < words2.length; index++) {
|
|
26746
|
+
const word = words2[index];
|
|
26485
26747
|
if (options.trim !== false) {
|
|
26486
26748
|
const row = rows.at(-1) ?? "";
|
|
26487
26749
|
const trimmed = row.trimStart();
|
|
@@ -28000,6 +28262,26 @@ function isInside(root, path2) {
|
|
|
28000
28262
|
const rel = relative(root, path2);
|
|
28001
28263
|
return rel === "" || rel.split(sep)[0] !== ".." && !isAbsolute(rel);
|
|
28002
28264
|
}
|
|
28265
|
+
function englishIssues(manifest) {
|
|
28266
|
+
const issues = [];
|
|
28267
|
+
const title = titleNotEnglish(manifest.title);
|
|
28268
|
+
if (title) {
|
|
28269
|
+
issues.push({
|
|
28270
|
+
code: "INVALID_MANIFEST",
|
|
28271
|
+
path: "title",
|
|
28272
|
+
message: `the title "${manifest.title}" reads as ${title.languageName}, and titles on JuicyJuicy are in English so every player can read them, even when the user chose this one. Tell the user, suggest an English title (what "${manifest.title}" means, or a new name), and publish with the one they pick. Only a name stays as it is (like "Kasztanek"). The game can keep its ${title.languageName} name on its own title screen.`
|
|
28273
|
+
});
|
|
28274
|
+
}
|
|
28275
|
+
const description = descriptionNotEnglish(manifest.description);
|
|
28276
|
+
if (description) {
|
|
28277
|
+
issues.push({
|
|
28278
|
+
code: "INVALID_MANIFEST",
|
|
28279
|
+
path: "description",
|
|
28280
|
+
message: `the description reads as ${description.languageName}: write it in English. The game itself can stay in ${description.languageName}, with "languages" listing both (English first).`
|
|
28281
|
+
});
|
|
28282
|
+
}
|
|
28283
|
+
return issues;
|
|
28284
|
+
}
|
|
28003
28285
|
async function checkProject(dir, options = {}) {
|
|
28004
28286
|
const { raw, manifest } = await loadManifest(dir);
|
|
28005
28287
|
const realDir = await realpath(dir);
|
|
@@ -28029,6 +28311,13 @@ async function checkProject(dir, options = {}) {
|
|
|
28029
28311
|
path: "features"
|
|
28030
28312
|
});
|
|
28031
28313
|
}
|
|
28314
|
+
check3.issues.push(...englishIssues(manifest));
|
|
28315
|
+
const letters2 = titleNotEnglish(manifest.title) ? null : titleLetters(manifest.title);
|
|
28316
|
+
if (letters2) {
|
|
28317
|
+
check3.warnings.push(
|
|
28318
|
+
`the title "${manifest.title}" has ${letters2.languageName} letters: fine for a name; if it's ${letters2.languageName} words, ask the user for an English title`
|
|
28319
|
+
);
|
|
28320
|
+
}
|
|
28032
28321
|
if (outside) {
|
|
28033
28322
|
check3.issues.push({
|
|
28034
28323
|
code: "INVALID_PATH",
|
|
@@ -28315,6 +28604,15 @@ async function writeManifest(dir, manifest) {
|
|
|
28315
28604
|
`);
|
|
28316
28605
|
return path2;
|
|
28317
28606
|
}
|
|
28607
|
+
function withGameId(manifest, id) {
|
|
28608
|
+
const { id: _old, ...rest } = manifest;
|
|
28609
|
+
const ordered = {};
|
|
28610
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
28611
|
+
ordered[key] = value;
|
|
28612
|
+
if (key === "slug") ordered.id = id;
|
|
28613
|
+
}
|
|
28614
|
+
return "id" in ordered ? ordered : { ...ordered, id };
|
|
28615
|
+
}
|
|
28318
28616
|
function splitList(value) {
|
|
28319
28617
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
28320
28618
|
}
|
|
@@ -28484,6 +28782,11 @@ async function captureScreenshot(browser, url2, {
|
|
|
28484
28782
|
"--disable-default-apps",
|
|
28485
28783
|
"--mute-audio",
|
|
28486
28784
|
"--hide-scrollbars",
|
|
28785
|
+
// The page's tab is the browser's second (after about:blank): on a busy machine Chrome may
|
|
28786
|
+
// treat it as hidden, delay its load event and frames, and never answer the screenshot.
|
|
28787
|
+
"--disable-background-timer-throttling",
|
|
28788
|
+
"--disable-backgrounding-occluded-windows",
|
|
28789
|
+
"--disable-renderer-backgrounding",
|
|
28487
28790
|
"--password-store=basic",
|
|
28488
28791
|
"--use-mock-keychain",
|
|
28489
28792
|
// No name resolves (the page is on 127.0.0.1), and WebRTC gets no direct connections:
|
|
@@ -28556,6 +28859,7 @@ async function captureScreenshot(browser, url2, {
|
|
|
28556
28859
|
flatten: true
|
|
28557
28860
|
});
|
|
28558
28861
|
await page("Page.enable");
|
|
28862
|
+
await page("Page.bringToFront");
|
|
28559
28863
|
await page("Emulation.setDeviceMetricsOverride", {
|
|
28560
28864
|
width,
|
|
28561
28865
|
height,
|
|
@@ -28937,6 +29241,18 @@ async function uploadFiles(tasks, options = {}) {
|
|
|
28937
29241
|
}
|
|
28938
29242
|
|
|
28939
29243
|
// src/commands/publish.ts
|
|
29244
|
+
async function saveGameId(out, root, publicId) {
|
|
29245
|
+
try {
|
|
29246
|
+
const raw = await readRawManifest(root);
|
|
29247
|
+
if (!raw || raw.id === publicId) return;
|
|
29248
|
+
await writeManifest(root, withGameId(raw, publicId));
|
|
29249
|
+
out.info(`Saved the game's "id" in juicy.json: it keeps this folder publishing to this game.`);
|
|
29250
|
+
} catch (error62) {
|
|
29251
|
+
out.warn(
|
|
29252
|
+
`couldn't save the game's "id" in juicy.json (${error62 instanceof Error ? error62.message : String(error62)}); the publish goes on`
|
|
29253
|
+
);
|
|
29254
|
+
}
|
|
29255
|
+
}
|
|
28940
29256
|
async function publish(out, dir, options) {
|
|
28941
29257
|
const root = resolve3(dir);
|
|
28942
29258
|
if (out.interactive && !await readRawManifest(root)) {
|
|
@@ -28982,6 +29298,7 @@ async function publish(out, dir, options) {
|
|
|
28982
29298
|
`${new URL(server).host} doesn't take drafts yet, so nothing was uploaded. Publish without --draft, or try again later.`
|
|
28983
29299
|
);
|
|
28984
29300
|
}
|
|
29301
|
+
await saveGameId(out, root, started.game.publicId);
|
|
28985
29302
|
out.event("version_started", {
|
|
28986
29303
|
slug: started.game.slug,
|
|
28987
29304
|
version: started.version,
|
|
@@ -29456,10 +29773,14 @@ async function pull(out, slug, dir) {
|
|
|
29456
29773
|
bar?.stop(`Downloaded ${downloads.length} files (${formatBytes(totalBytes)})`);
|
|
29457
29774
|
const written = new Set(downloads.map((file2) => file2.path));
|
|
29458
29775
|
const { thumbnail, ...rest } = source.manifest;
|
|
29459
|
-
|
|
29776
|
+
const manifest = {
|
|
29460
29777
|
$schema: MANIFEST_SCHEMA_URL,
|
|
29461
29778
|
...thumbnail && written.has(thumbnail) ? source.manifest : rest
|
|
29462
|
-
}
|
|
29779
|
+
};
|
|
29780
|
+
await writeManifest(
|
|
29781
|
+
root,
|
|
29782
|
+
source.game.publicId ? withGameId(manifest, source.game.publicId) : manifest
|
|
29783
|
+
);
|
|
29463
29784
|
const shown = relative5(process.cwd(), root) || ".";
|
|
29464
29785
|
const next = [
|
|
29465
29786
|
`Work in ${root}: it's the live version ${game.version} of "${game.slug}", with its juicy.json.`,
|
|
@@ -29582,10 +29903,10 @@ var SKILL_NAME = "juicyjuicy-publish";
|
|
|
29582
29903
|
function skillSourceDir() {
|
|
29583
29904
|
const here = dirname3(fileURLToPath(import.meta.url));
|
|
29584
29905
|
const candidates = [join9(here, "skill"), resolve7(here, "../../../skill/skills", SKILL_NAME)];
|
|
29585
|
-
const
|
|
29586
|
-
if (!
|
|
29906
|
+
const found2 = candidates.find((dir) => existsSync3(join9(dir, "SKILL.md")));
|
|
29907
|
+
if (!found2)
|
|
29587
29908
|
throw new CliError("INTERNAL_ERROR", "This copy of the CLI doesn't include the skill.");
|
|
29588
|
-
return
|
|
29909
|
+
return found2;
|
|
29589
29910
|
}
|
|
29590
29911
|
function skillTargets(env = process.env, home = homedir2()) {
|
|
29591
29912
|
const claude = env.CLAUDE_CONFIG_DIR?.trim() || join9(home, ".claude");
|
|
@@ -29785,7 +30106,10 @@ ${REMIX_LICENSE_NAME} (--remixable): ${REMIX_LICENSE}`
|
|
|
29785
30106
|
"--period <period>",
|
|
29786
30107
|
"window for the upvoted/downvoted/played sorts (default all)"
|
|
29787
30108
|
).choices(GAME_PERIODS)
|
|
29788
|
-
).addOption(new Option("--engine <engine>", "only this engine").choices(ENGINES)).option("--tag <tag>", "only games with this tag, e.g. puzzle
|
|
30109
|
+
).addOption(new Option("--engine <engine>", "only this engine").choices(ENGINES)).option("--tag <tag>", "only games with this tag, e.g. puzzle, or any of several: cards,dice").option(
|
|
30110
|
+
"--search <text>",
|
|
30111
|
+
"only games with this text in the title, description, creator, tags or made-with"
|
|
30112
|
+
).option("--remixes", "only remixes").option("--multiplayer", "only games with multiplayer rooms").option("--highscores", "only games with global highscores (leaderboards)").option("--page <n>", "page of --all to show (default 1)").addHelpText(
|
|
29789
30113
|
"after",
|
|
29790
30114
|
`
|
|
29791
30115
|
Examples:
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -9,19 +9,19 @@ JuicyJuicy hosts **static browser games** (HTML/JS/CSS/assets, WebAssembly) and
|
|
|
9
9
|
|
|
10
10
|
Run every CLI command with `--json`. Each output line is a JSON object; the **last** line is the result, either `{"ok": true, …}` or `{"ok": false, "error": {"code", "message", "details"?, "hint"?}}`. The exit code is `0` (ok), `1` (fix the game or `juicy.json`), `2` (log in) or `3` (network or server problem, so retry).
|
|
11
11
|
|
|
12
|
-
Run the CLI exactly as written here, `npx -y juicyjuicy@0.
|
|
12
|
+
Run the CLI exactly as written here, `npx -y juicyjuicy@0.11.1 …`: this skill is made for that release. If JuicyJuicy answers that the CLI is out of date, run the command its message gives. It updates the CLI and this skill together; read this skill again afterwards and use the release it names from then on.
|
|
13
13
|
|
|
14
14
|
## Procedure
|
|
15
15
|
|
|
16
16
|
### 1. Make sure the CLI is logged in
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
npx -y juicyjuicy@0.
|
|
19
|
+
npx -y juicyjuicy@0.11.1 whoami --json
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
If it exits with `2`, log in. `login` opens the user's browser and **waits up to 10 minutes** for them to approve:
|
|
23
23
|
|
|
24
|
-
- Run `npx -y juicyjuicy@0.
|
|
24
|
+
- Run `npx -y juicyjuicy@0.11.1 login --json` in the background if your shell can, and read its first output line (`{"event": "device_code", "userCode", "verificationUrl"}`) right away, and show the user the code and link without waiting for them to ask: the page shows the same code, and that's how they know the login is theirs.
|
|
25
25
|
- Then tell the user, with the real code and link: *"A JuicyJuicy page opened in your browser. Check it shows the code **WXTZ-BCDF** and click **Approve**. If no page opened, go to <verificationUrl>."*
|
|
26
26
|
- If your shell can't run commands in the background, say that first (without a code: it doesn't exist yet), then run it in the foreground with a timeout of at least 10 minutes. The code is also printed as `Your code: …`.
|
|
27
27
|
- The user needs a JuicyJuicy account (it's invite-only for now). If the page says they can't sign in, they need an invite link from a friend who's a member.
|
|
@@ -45,7 +45,7 @@ Files and folders starting with `.` (`.env`, `.git/`, …) and `node_modules/` a
|
|
|
45
45
|
`juicy.json` sits in the project folder and describes the game. Create or update it with `init`, filling in what you know from the game you built:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
npx -y juicyjuicy@0.
|
|
48
|
+
npx -y juicyjuicy@0.11.1 init --yes --json \
|
|
49
49
|
--title "Snake Deluxe" \
|
|
50
50
|
--description "Eat the apples, don't bite your tail. Arrow keys or swipe." \
|
|
51
51
|
--made-with "<your model name>, <the tool you run in>" \
|
|
@@ -54,7 +54,7 @@ npx -y juicyjuicy@0.10.0 init --yes --json \
|
|
|
54
54
|
|
|
55
55
|
- `init` keeps what an existing `juicy.json` already says (flags override), and detects `buildDir`, `entry` and `engine`. Check what it wrote.
|
|
56
56
|
- `--made-with`: your real model and the tool you run in, e.g. `"Claude Opus 5.5, Claude Code"` or `"Gemini 3 Pro, Antigravity"`.
|
|
57
|
-
- The slug (the `/g/<slug>` URL name) defaults to one made from the title. Once published it belongs to this user; reuse it to publish updates.
|
|
57
|
+
- The slug (the `/g/<slug>` URL name) defaults to one made from the title. Once published it belongs to this user; reuse it to publish updates. **Never change the slug of a published game**, not even when the user renames it: change `title` only. A new slug is a new game. After the first publish, `juicy.json` also has an `id` that keeps the folder tied to its game; leave it as it is.
|
|
58
58
|
- `--languages`: the languages a player can switch between, English first, e.g. `en,pl`. Leave it out for a game that's only in English (the default is `en`).
|
|
59
59
|
|
|
60
60
|
All fields: [reference/manifest.md](reference/manifest.md).
|
|
@@ -63,6 +63,7 @@ All fields: [reference/manifest.md](reference/manifest.md).
|
|
|
63
63
|
|
|
64
64
|
- If the game's text (menus, buttons, instructions, dialogue, text drawn in images) is in another language, build an English version before publishing: keep the player-facing text in one table per language, start in English, and add a visible switch (like `EN | PL`) to the language the game was made in. Tell the user in a sentence that you did. Pass both with `--languages en,pl`.
|
|
65
65
|
- `title` and `description` are always in English. A title that is a name (`Kasztanek`) stays as it is; one made of words in another language gets translated (`Wielka Ucieczka` → `The Great Escape`). Tell the user the English title you chose.
|
|
66
|
+
- **This holds when the user names the game too.** "Publish it as Kalkulator na Etacie" tells you what the game is called, not that the rule is off: they may not know titles are in English. Before publishing, say so and suggest an English title ("Titles on JuicyJuicy are in English. How about *The Salaried Calculator*? The game keeps *Kalkulator na Etacie* on its own title screen."), then use the one they pick. `check` and `publish` refuse a title or description that clearly isn't English (`INVALID_MANIFEST` on `title` or `description`).
|
|
66
67
|
- Text in the thumbnail (step 4) is in English too.
|
|
67
68
|
|
|
68
69
|
### 4. Add a thumbnail
|
|
@@ -84,7 +85,7 @@ To replace it, the user saves a screenshot as `thumbnail.png` in the project fol
|
|
|
84
85
|
|
|
85
86
|
Do this **every time**, also for updates. It protects the user and their players; JuicyJuicy's server runs its own checks as well, so this is help, not a formality to get past.
|
|
86
87
|
|
|
87
|
-
1. **Check.** Run `npx -y juicyjuicy@0.
|
|
88
|
+
1. **Check.** Run `npx -y juicyjuicy@0.11.1 check --json`. Errors (a failed result) must be fixed: explain each one (`details` has a `rule` and a `path`), and offer to fix it. If the user says no, **stop and don't publish**: the server would refuse it anyway. Warnings in the result (`findings`) go into step 2.
|
|
88
89
|
Every file in the result's `files` will be public. If `unusualFiles` isn't empty (names that look like keys or private data, project files like `package.json`, notes and other files a web game doesn't load), **show the user that list and ask whether those files should be public** before publishing; offer to move them out of the build folder or set `buildDir` to a folder with just the game.
|
|
89
90
|
2. **Security review.** Follow [reference/review.md](reference/review.md): keys and tokens, `innerHTML`/`eval` on network or multiplayer data, `http://` loads, trackers, login or payment forms, anything that leaves the page. Same rule: for what it marks as must-fix, fix it or don't publish.
|
|
90
91
|
3. **Standards.** Check the game against these and tell the user, in a sentence or two, what falls short; offer to fix it. A "no" still publishes: not every game can have touch controls.
|
|
@@ -113,7 +114,7 @@ Keep it short when there's nothing to say: "Checked: no problems found" is enoug
|
|
|
113
114
|
### 6. Publish
|
|
114
115
|
|
|
115
116
|
```bash
|
|
116
|
-
npx -y juicyjuicy@0.
|
|
117
|
+
npx -y juicyjuicy@0.11.1 publish --json --message "<what changed, in a few words>"
|
|
117
118
|
```
|
|
118
119
|
|
|
119
120
|
This makes the new version live: when the user says "publish", that's what they want. Don't add `--draft` (see "Other commands") unless they asked in words for it not to go live yet. Not for a first publish, a big game or one you're unsure of either: the user would have to find the dashboard and make it live themselves.
|
|
@@ -140,7 +141,7 @@ The game's page under My games on JuicyJuicy has an **Upgrades** panel; its Add
|
|
|
140
141
|
|
|
141
142
|
Other features on the panel (touch controls, gamepad, mouse lock, auto-pause) are "Coming soon" and have no recipe yet: if asked for one, say so, and build it your own way only if the user wants.
|
|
142
143
|
|
|
143
|
-
1. **Find the project.** Log in first (step 1). The project is the folder whose `juicy.json` has the game's `slug`: the current folder, or one the user points you to. **If it isn't on this computer** (or you're in a chat app or sandbox), download the live version: `npx -y juicyjuicy@0.
|
|
144
|
+
1. **Find the project.** Log in first (step 1). The project is the folder whose `juicy.json` has the game's `slug`: the current folder, or one the user points you to. **If it isn't on this computer** (or you're in a chat app or sandbox), download the live version: `npx -y juicyjuicy@0.11.1 pull <slug> --json` writes it into a new folder named after the slug (`dir` in the result) with its `juicy.json`, and you work there. Read the result's `next`: the download is the built game, so for a bundled one (minified code), ask whether the user has the source somewhere, or use `sourceUrl`, before changing the bundle itself. `pull` only works for the user's own games (`NOT_FOUND` otherwise).
|
|
144
145
|
2. **Read the recipe** for the upgrade and make the change. Keep everything else about the game as it is.
|
|
145
146
|
3. **Build and try it** (step 2), then **declare it** in `juicy.json` as the table says, adding to what's already listed.
|
|
146
147
|
4. **Review and publish** as steps 5–7 say, with `--message "Add <upgrade>"`. In step 5's `check` result, `features.detected` lists what JuicyJuicy found in the files; a `feature-missing` warning means a declared upgrade isn't there yet: fix it before publishing.
|
|
@@ -148,14 +149,14 @@ Other features on the panel (touch controls, gamepad, mouse lock, auto-pause) ar
|
|
|
148
149
|
|
|
149
150
|
## Bring all my games up to date
|
|
150
151
|
|
|
151
|
-
When the user asks to bring their JuicyJuicy games up to date or fix a **Needs update** badge, when `check` warns `requirement-missing`, or after updating the CLI: follow [reference/update-all-games.md](reference/update-all-games.md). It starts with `npx -y juicyjuicy@0.
|
|
152
|
+
When the user asks to bring their JuicyJuicy games up to date or fix a **Needs update** badge, when `check` warns `requirement-missing`, or after updating the CLI: follow [reference/update-all-games.md](reference/update-all-games.md). It starts with `npx -y juicyjuicy@0.11.1 check-games --json`, which checks every game of the user's at once and settles what a script can; the rest are code changes, one game at a time.
|
|
152
153
|
|
|
153
154
|
## Remix a game
|
|
154
155
|
|
|
155
156
|
A game marked remixable on JuicyJuicy can be downloaded, changed and published as a new game that links back to it. Log in first (step 1), then:
|
|
156
157
|
|
|
157
158
|
```bash
|
|
158
|
-
npx -y juicyjuicy@0.
|
|
159
|
+
npx -y juicyjuicy@0.11.1 remix <slug> --json
|
|
159
160
|
```
|
|
160
161
|
|
|
161
162
|
- The first line is `{"event": "license", "name", "text"}`: the remix license. Tell the user in a sentence what it says. In short: the remix may be published **on JuicyJuicy only**, never anywhere else, and the files aren't to be passed on. The full text is:
|
|
@@ -178,8 +179,9 @@ Read the error's `code`, `message` and `details` (a list of `{path, message}`, p
|
|
|
178
179
|
|---|---|
|
|
179
180
|
| `UNAUTHORIZED`, `EXPIRED_TOKEN` | Log in again (step 1). |
|
|
180
181
|
| `ENTRY_MISSING` | Build first, or point `buildDir`/`entry` at the folder/file that exists. |
|
|
181
|
-
| `INVALID_MANIFEST` | Fix the fields listed in `details` in `juicy.json`. |
|
|
182
|
+
| `INVALID_MANIFEST` | Fix the fields listed in `details` in `juicy.json`. For a `title` or `description` that isn't English, ask the user for an English title first (step 3): don't pick one silently, and don't work around the check. |
|
|
182
183
|
| `SLUG_TAKEN` | Someone else owns that slug: pick another (`init --slug …`). |
|
|
184
|
+
| `SLUG_CHANGED` | `juicy.json`'s `slug` no longer matches the game its `id` belongs to. To update that game (the usual case, e.g. after a rename), set `slug` back to the one the message names and keep the new `title`. Only if the user wants this folder published as a separate new game, remove `id`. Ask the user if unsure; never do both. |
|
|
183
185
|
| `NOT_REMIXABLE` | The game's creator hasn't allowed remixes. Don't work around it: tell the user. |
|
|
184
186
|
| `UNSAFE_CONTENT` | A key or token, a program (`.exe`, `.sh`, …) or mining code is in the game. Explain it, offer to remove it, and for a key tell the user to revoke it. Never try to hide it from the check. |
|
|
185
187
|
| `TOO_LARGE`, `TOO_MANY_FILES` | Shrink assets or drop unused files (limits: 150 MB, 3,000 files, 100 MB per file). |
|
|
@@ -190,9 +192,9 @@ Everything else is in [reference/troubleshooting.md](reference/troubleshooting.m
|
|
|
190
192
|
|
|
191
193
|
## Other commands
|
|
192
194
|
|
|
193
|
-
- `npx -y juicyjuicy@0.
|
|
194
|
-
- `npx -y juicyjuicy@0.
|
|
195
|
-
- `npx -y juicyjuicy@0.
|
|
196
|
-
- `npx -y juicyjuicy@0.
|
|
197
|
-
- `npx -y juicyjuicy@0.
|
|
198
|
-
- `npx -y juicyjuicy@0.
|
|
195
|
+
- `npx -y juicyjuicy@0.11.1 list --json`: the user's games; `list <slug>` shows one game's versions. `--all` lists every game on JuicyJuicy instead (60 a page, `--page 2` and on; `nextPage` in the result), `--owner <username>` another creator's. Narrow and order any list with `--search <title text>`, `--tag`, `--engine`, `--remixes`, `--multiplayer` (games with rooms), `--highscores` (games with a leaderboard), `--sort newest|upvoted|downvoted|played` and `--period 24h|week|month|all`, e.g. to find a game to remix.
|
|
196
|
+
- `npx -y juicyjuicy@0.11.1 publish --draft --json`: uploads a new version without making it live (players keep the current one). Only when the user asks for that in words (e.g. "upload it but don't put it live yet"), never on your own judgment. The result has `draft: true`; when they say so, make it live with `rollback <version>`, or they can on the dashboard.
|
|
197
|
+
- `npx -y juicyjuicy@0.11.1 rollback <version> --slug <slug> --json`: makes an earlier version or a draft live, e.g. when the user says the last update broke the game. Run `list <slug>` first to pick the version with the user; one waiting for an admin's review can't be made live (`FORBIDDEN`).
|
|
198
|
+
- `npx -y juicyjuicy@0.11.1 pull <slug> --json`: downloads the live version of one of the user's own games into a new folder with its `juicy.json`, to change it when the project isn't on this computer (see "Add a JuicyJuicy upgrade").
|
|
199
|
+
- `npx -y juicyjuicy@0.11.1 check-games --json`: checks all the user's games against JuicyJuicy's features and required updates, declares the features it finds where a game never declared them, and lists what still needs a change (see "Bring all my games up to date").
|
|
200
|
+
- `npx -y juicyjuicy@0.11.1 open <slug>`: opens the game page in the browser.
|
|
@@ -16,4 +16,4 @@ JuicyJuicy offers English, French, Italian, German, Spanish and Polish (`en`, `f
|
|
|
16
16
|
Run the game, switch to each language and go through the menus and the first minute of play: no missing keys (an untranslated key shows up as the key's name), no clipped text, and the choice is still there after a reload.
|
|
17
17
|
|
|
18
18
|
## Declare it
|
|
19
|
-
Set `languages` in `juicy.json` to every language the game now has, English first, e.g. `"languages": ["en", "fr", "pl"]` (or `npx -y juicyjuicy@0.
|
|
19
|
+
Set `languages` in `juicy.json` to every language the game now has, English first, e.g. `"languages": ["en", "fr", "pl"]` (or `npx -y juicyjuicy@0.11.1 init --yes --json --languages en,fr,pl`). Don't add it to `features`: JuicyJuicy reads `languages`, and shows the game in each language's filter.
|
|
@@ -26,7 +26,8 @@ The manifest in the project folder. `juicyjuicy init` writes it; edit it by hand
|
|
|
26
26
|
|
|
27
27
|
| Field | Required | Rules | `init` flag |
|
|
28
28
|
|---|---|---|---|
|
|
29
|
-
| `slug` | yes | 3–40 chars, lowercase letters, digits, single dashes. The game's URL: `/g/<slug>`. Owned by whoever publishes it first. | `--slug` |
|
|
29
|
+
| `slug` | yes | 3–40 chars, lowercase letters, digits, single dashes. The game's URL: `/g/<slug>`. Owned by whoever publishes it first. Don't change it once published: a new slug is a new game (renaming a game means a new `title`). | `--slug` |
|
|
30
|
+
| `id` | | Written by `publish` (and `pull`): the game this folder publishes to. Don't edit. A publish whose `slug` names another game is refused with `SLUG_CHANGED`; remove `id` only to publish a copy of the folder as a separate new game. | |
|
|
30
31
|
| `title` | yes | Up to 60 characters. | `--title` |
|
|
31
32
|
| `description` | | Up to 500 characters. One or two sentences: what you do, how you control it. | `--description` |
|
|
32
33
|
| `buildDir` | | Folder that gets uploaded, relative to the project. Default `.`. | `--build-dir` |
|
|
@@ -21,12 +21,12 @@ Don't build anything yet. **Ask the user how multiplayer should work in this gam
|
|
|
21
21
|
|
|
22
22
|
1. **What kind of play:** together against the game (co-op), against each other (versus), taking turns, or a race or party round?
|
|
23
23
|
2. **How many players:** 2 to 10.
|
|
24
|
-
3. **How players join:**
|
|
24
|
+
3. **How players join:** through the default menu (see "The menu"): **Multiplayer** → **Search lobby** (a list of public lobbies, plus a code box) or **Host lobby** (a lobby the host switches between Public and Private, with an Invite link). Say you'll build that unless they want something else. Can someone join a game that has started, or only in the lobby?
|
|
25
25
|
4. **When someone leaves:** does the game go on without them? When the host leaves, the next player takes over (see "When the host changes"); is that right for this game?
|
|
26
26
|
5. **Phones:** should it play on phones (touch controls)?
|
|
27
27
|
6. **Anything else:** teams, rounds, a winner screen, what each player sees of the others.
|
|
28
28
|
|
|
29
|
-
Then write the design back in a few lines: the screens (menu,
|
|
29
|
+
Then write the design back in a few lines: the screens (the default menu's, then game and results), what the host runs, what each player sends and what the host sends back. **Build it only once the user agrees**, then follow "The menu", "Using it" and "How to build it".
|
|
30
30
|
|
|
31
31
|
## The snippet
|
|
32
32
|
Paste it as is, before the game's own code: an inline `<script>` in `index.html`, or the top of the main script. Don't change it. It works next to the cloud-saves snippet, in either order (an older copy of the cloud-saves snippet, without `window.juicy.save ||` near its top, must come first).
|
|
@@ -486,27 +486,69 @@ window.juicy.rooms || (function (juicy) {
|
|
|
486
486
|
})(window.juicy);
|
|
487
487
|
```
|
|
488
488
|
|
|
489
|
+
## The menu
|
|
490
|
+
Every game gets this multiplayer menu unless the user asks for another. Build it as the game's own screens, in its own look (fonts, colors, button style), and make every screen work at phone width and by keyboard.
|
|
491
|
+
|
|
492
|
+
1. **Main menu:** a **Multiplayer** button next to the game's own buttons (Play, Settings…).
|
|
493
|
+
2. **Multiplayer:** two buttons, **Search lobby** and **Host lobby**, and **Back**.
|
|
494
|
+
3. **Search lobby:**
|
|
495
|
+
- A small framed box listing the public lobbies from `juicy.rooms.list()`: on each row the host's login (with `textContent`), the players (`2/4`), the label in the game's own words if the game uses labels, and a **Join** button. The box keeps its size and scrolls inside when the list is long.
|
|
496
|
+
- A **refresh icon button** (↻, with `aria-label="Refresh"`) at the box's top right. The list loads once when the screen opens and again only when the player presses it (each read counts against the player's limit; never more often than every 5 seconds).
|
|
497
|
+
- Under the box, a line "Private lobby? Enter its code", a **code box** (6 letters, upper case, `maxlength="6"`) and a **Join** button; Enter in the box joins too.
|
|
498
|
+
- An empty list says so in the box ("No open lobbies. Host one!") and keeps the code box working.
|
|
499
|
+
- **Back**.
|
|
500
|
+
4. **Host lobby** opens a room with `create({ max, public: true })` and goes straight to the lobby screen. **Join** from Search or the code box goes there too.
|
|
501
|
+
5. **The lobby:**
|
|
502
|
+
- The **lobby code** big and spaced out (`K7QX2M`), so it can be read out.
|
|
503
|
+
- For the host only, a **Public / Private** switch: Public is listed in Search lobby; Private is only for people with the code or invite link (`room.setPublic(false)`, `room.setPublic()`), with that line under it. It starts on Public; a game meant only for friends (or a user who asks) starts on Private instead. Everyone else sees which it is as text, without the switch.
|
|
504
|
+
- The players, the host marked (a crown or "(host)"), with empty "Waiting…" slots up to `room.max` (`2/4`).
|
|
505
|
+
- **Invite** (`room.invite()`: JuicyJuicy's share sheet), **Start** for the host only (the others see "Waiting for the host"), and **Leave**.
|
|
506
|
+
6. **Opened from an invite link** (`juicy.rooms.invited()`): skip the menu and join that room's lobby.
|
|
507
|
+
|
|
508
|
+
Errors (a wrong code, a full room, signed out) show as a short line on the screen the player is on, which stays open. A closed room goes back to the Multiplayer screen with the reason.
|
|
509
|
+
|
|
489
510
|
## Using it
|
|
490
511
|
```js
|
|
491
|
-
// Opened from an invite link: join that
|
|
512
|
+
// Opened from an invite link: join that lobby straight away.
|
|
492
513
|
const code = await juicy.rooms.invited();
|
|
493
|
-
if (code)
|
|
514
|
+
if (code) enter(() => juicy.rooms.join(code));
|
|
494
515
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
516
|
+
multiplayerButton.onclick = () => showScreen("multiplayer"); // Search lobby, Host lobby, Back
|
|
517
|
+
hostButton.onclick = () => enter(() => juicy.rooms.create({ max: 4, public: true }));
|
|
518
|
+
searchButton.onclick = () => { showScreen("search"); refresh(); };
|
|
519
|
+
refreshButton.onclick = refresh; // the ↻ icon at the list's top right
|
|
520
|
+
joinCodeButton.onclick = () => enter(() => juicy.rooms.join(codeInput.value));
|
|
498
521
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
async function refresh() {
|
|
523
|
+
const rooms = await juicy.rooms.list(); // [{ code, host, players, max, label }]
|
|
524
|
+
// One row per room: host login and players (textContent), and a Join button that runs
|
|
525
|
+
// enter(() => juicy.rooms.join(room.code)). None: "No open lobbies. Host one!"
|
|
526
|
+
showRooms(rooms);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function enter(open) {
|
|
530
|
+
try {
|
|
531
|
+
start(await open());
|
|
532
|
+
} catch (error) {
|
|
533
|
+
showError(error.code); // a short line on this screen: wrong code, full, signed out…
|
|
534
|
+
}
|
|
535
|
+
}
|
|
502
536
|
|
|
503
537
|
function start(room) {
|
|
504
|
-
|
|
538
|
+
showScreen("lobby");
|
|
539
|
+
showLobby(room); // room.code, room.players ([{ id: "p1", name: "luk" }, …], with textContent), room.max
|
|
540
|
+
publicSwitch.hidden = !room.isHost();
|
|
541
|
+
publicSwitch.onchange = () => room.setPublic(publicSwitch.checked); // Public / Private
|
|
505
542
|
inviteButton.onclick = () => room.invite(); // JuicyJuicy's share sheet, with Copy link
|
|
506
|
-
|
|
507
|
-
room.
|
|
508
|
-
|
|
509
|
-
room.on("
|
|
543
|
+
startButton.hidden = !room.isHost();
|
|
544
|
+
startButton.onclick = () => { room.send({ k: "start" }); room.lock(); beginGame(room); };
|
|
545
|
+
leaveButton.onclick = () => room.leave();
|
|
546
|
+
room.on("join", () => showLobby(room));
|
|
547
|
+
room.on("leave", () => showLobby(room));
|
|
548
|
+
room.on("host", () => showLobby(room)); // the new host gets the switch and Start
|
|
549
|
+
room.on("public", (isPublic) => showPublic(isPublic));
|
|
550
|
+
room.on("message", (data, from) => (data.k === "start" ? beginGame(room) : handle(data, from)));
|
|
551
|
+
room.on("close", (reason) => { showScreen("multiplayer"); showError(reason); });
|
|
510
552
|
}
|
|
511
553
|
```
|
|
512
554
|
|
|
@@ -524,11 +566,10 @@ function start(room) {
|
|
|
524
566
|
## How to build it
|
|
525
567
|
- **The host runs the game.** Players send their input (`{ k: "input", left: true }`, a move, a card played); the host applies it to the one true game state and sends the state, or what changed, to everyone. Players draw what the host sent and never decide outcomes themselves. For a real-time game, send state 10 to 20 times a second and smooth movement between updates; send input when it changes, not every frame.
|
|
526
568
|
- **Small messages with a kind:** `{ k: "state", … }`, `{ k: "input", … }`. Each message is at most 16 KB as JSON, and each player may send at most 60 messages and 64 KB a second (one `send` to everyone counts once). Over that, messages are dropped (`RATE_LIMITED`), and far over it the player is disconnected (`TOO_FAST`).
|
|
527
|
-
- **The lobby
|
|
528
|
-
- **Public
|
|
529
|
-
- Show the list as the game's own screen: each room's host login (with `textContent`), its players (`2/4`) and its label in the game's own words, with **Join**, and a **Refresh** button. Refresh when the player asks or at most every 5 seconds while the list is showing (each read counts against the player's limit). An empty list says so and offers to host one.
|
|
569
|
+
- **The lobby** is the screen in "The menu". When the game starts, the host sends `{ k: "start", … }` and calls `room.lock()` if nobody may join mid-game. If players may drop in, the host sends `{ k: "start" }` to each player who joins (`room.send(data, player.id)`) so they go straight into the game.
|
|
570
|
+
- **Public and private lobbies:** Host lobby opens a public room, which Search lobby lists; the host's switch makes it private (`room.setPublic(false)`: only the code and the invite link get in) and public again (`room.setPublic()`). The `"public"` event tells everyone, so the lobby can show which it is.
|
|
530
571
|
- Lock the room (`room.lock()`) when the round starts unless players may drop in: that takes it off the list. Unlock it after the round to let the next players in.
|
|
531
|
-
- A label is only the game's own id, from a fixed set in the code. **Never** put text a player typed into a label: no
|
|
572
|
+
- **Labels** are optional, for a game with modes or maps in Search lobby (`create({ public: true, label: "duel" })`, `list({ label })`). A label is only the game's own id, from a fixed set in the code. **Never** put text a player typed into a label: no lobby names (there's no moderation; the security review treats it like free-text chat).
|
|
532
573
|
- **When the host changes** (the host left or dropped): every player keeps the last state the host sent, so on `"host"` the new host (`room.isHost()`) carries on from it and starts sending state. A turn-based game resends whose turn it is.
|
|
533
574
|
- **When a player leaves**, the host decides: remove their character, let the round finish, or end the game if too few are left.
|
|
534
575
|
- **Dropped connections:** phones drop them when the game goes to the background. The snippet reconnects by itself (three tries) and keeps the player's id; during that time `send` does nothing. When the host comes back, it asks for nothing: players resend what they need on `"join"` or `"host"`.
|
|
@@ -6,7 +6,7 @@ With `--json`, a failed command's last line is `{"ok": false, "error": {"code",
|
|
|
6
6
|
|
|
7
7
|
| Code | Meaning | Fix |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| `UNAUTHORIZED` | No saved login, or the token was revoked. | `npx -y juicyjuicy@0.
|
|
9
|
+
| `UNAUTHORIZED` | No saved login, or the token was revoked. | `npx -y juicyjuicy@0.11.1 login --json` (the user approves in the browser). In CI, set `JUICY_TOKEN`. |
|
|
10
10
|
| `EXPIRED_TOKEN` | The login code expired (10 minutes) or was used. | Run `login` again. |
|
|
11
11
|
| `ACCESS_DENIED` | The user clicked Deny. | Ask them, then run `login` again. |
|
|
12
12
|
|
|
@@ -5,7 +5,7 @@ For "bring my JuicyJuicy games up to date", "fix the required updates", a **Need
|
|
|
5
5
|
1. **Check every game at once.** Log in first (SKILL.md step 1), then run:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx -y juicyjuicy@0.
|
|
8
|
+
npx -y juicyjuicy@0.11.1 check-games --json
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
JuicyJuicy checks the live version of each of the user's games on its side. It records which features each one has and, for a game whose `juicy.json` never listed its `features`, declares what it finds in the files (like cloud saves). Nothing is uploaded and no new version is made. The result has one entry per game in `games` (`features` with `on`/`off`/`unknown`, `requirements` with `passed`), `toFix` (the games that still need a code change, each with its `prompts`), `notReached` and `next`.
|
|
@@ -13,7 +13,7 @@ For "bring my JuicyJuicy games up to date", "fix the required updates", a **Need
|
|
|
13
13
|
2. **If `notReached` isn't empty**, run `check-games` again: it had no time for those games.
|
|
14
14
|
|
|
15
15
|
3. **For each game in `toFix`**, one at a time:
|
|
16
|
-
- Find its project, or download the live version with `npx -y juicyjuicy@0.
|
|
16
|
+
- Find its project, or download the live version with `npx -y juicyjuicy@0.11.1 pull <slug> --json` (SKILL.md, "Add a JuicyJuicy upgrade", step 1).
|
|
17
17
|
- Do what each prompt says, with the recipe it names when it names one.
|
|
18
18
|
- Make sure `juicy.json` has `features` (`[]` if the game has none of the upgrades).
|
|
19
19
|
- Check and publish an update as SKILL.md steps 5–7 say, with `--message "Required update: <what>"`.
|