commit #3
This commit is contained in:
parent
14d7efe0bf
commit
8f54f121f1
31 changed files with 929 additions and 114 deletions
|
@ -4,10 +4,11 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:ui": "vite",
|
"dev:ui": "vite",
|
||||||
"dev:server": "tsx watch ./src/server/main.ts"
|
"dev:server": "NODE_ENV=development tsx watch --clear-screen=false ./src/server/main.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/ph": "^1.1.5",
|
"@iconify-json/ph": "^1.1.5",
|
||||||
|
"@types/cookie": "^0.5.1",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/lodash-es": "^4.17.7",
|
"@types/lodash-es": "^4.17.7",
|
||||||
"@types/node": "18",
|
"@types/node": "18",
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
"prisma": "^4.13.0",
|
"prisma": "^4.13.0",
|
||||||
"sass": "^1.62.0",
|
"sass": "^1.62.0",
|
||||||
"tsx": "^3.12.6",
|
"tsx": "^3.12.6",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
"unplugin-icons": "^0.16.1",
|
"unplugin-icons": "^0.16.1",
|
||||||
"vite": "^4.2.2",
|
"vite": "^4.2.2",
|
||||||
"vite-plugin-pages": "^0.29.0",
|
"vite-plugin-pages": "^0.29.0",
|
||||||
|
@ -29,15 +31,19 @@
|
||||||
"@prisma/client": "^4.13.0",
|
"@prisma/client": "^4.13.0",
|
||||||
"@trpc/client": "^10.20.0",
|
"@trpc/client": "^10.20.0",
|
||||||
"@trpc/server": "^10.20.0",
|
"@trpc/server": "^10.20.0",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@vueuse/core": "^10.0.2",
|
"@vueuse/core": "^10.0.2",
|
||||||
"@vueuse/integrations": "^10.0.2",
|
"@vueuse/integrations": "^10.0.2",
|
||||||
"bufferutil": "^4.0.7",
|
"bufferutil": "^4.0.7",
|
||||||
|
"cookie": "^0.5.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"eventemitter3": "^5.0.0",
|
"eventemitter3": "^5.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"immer": "^10.0.1",
|
"immer": "^10.0.1",
|
||||||
"listhen": "^1.0.4",
|
"listhen": "^1.0.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
|
"pinia": "^2.0.34",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.13.0",
|
||||||
|
|
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
|
@ -11,6 +11,8 @@ importers:
|
||||||
'@prisma/client': ^4.13.0
|
'@prisma/client': ^4.13.0
|
||||||
'@trpc/client': ^10.20.0
|
'@trpc/client': ^10.20.0
|
||||||
'@trpc/server': ^10.20.0
|
'@trpc/server': ^10.20.0
|
||||||
|
'@types/cookie': ^0.5.1
|
||||||
|
'@types/cookie-parser': ^1.4.3
|
||||||
'@types/express': ^4.17.17
|
'@types/express': ^4.17.17
|
||||||
'@types/lodash-es': ^4.17.7
|
'@types/lodash-es': ^4.17.7
|
||||||
'@types/node': '18'
|
'@types/node': '18'
|
||||||
|
@ -19,15 +21,19 @@ importers:
|
||||||
'@vueuse/core': ^10.0.2
|
'@vueuse/core': ^10.0.2
|
||||||
'@vueuse/integrations': ^10.0.2
|
'@vueuse/integrations': ^10.0.2
|
||||||
bufferutil: ^4.0.7
|
bufferutil: ^4.0.7
|
||||||
|
cookie: ^0.5.0
|
||||||
|
cookie-parser: ^1.4.6
|
||||||
eventemitter3: ^5.0.0
|
eventemitter3: ^5.0.0
|
||||||
express: ^4.18.2
|
express: ^4.18.2
|
||||||
immer: ^10.0.1
|
immer: ^10.0.1
|
||||||
listhen: ^1.0.4
|
listhen: ^1.0.4
|
||||||
lodash-es: ^4.17.21
|
lodash-es: ^4.17.21
|
||||||
nanoid: ^4.0.2
|
nanoid: ^4.0.2
|
||||||
|
pinia: ^2.0.34
|
||||||
prisma: ^4.13.0
|
prisma: ^4.13.0
|
||||||
sass: ^1.62.0
|
sass: ^1.62.0
|
||||||
tsx: ^3.12.6
|
tsx: ^3.12.6
|
||||||
|
typescript: ^4.9.5
|
||||||
unplugin-icons: ^0.16.1
|
unplugin-icons: ^0.16.1
|
||||||
vite: ^4.2.2
|
vite: ^4.2.2
|
||||||
vite-plugin-pages: ^0.29.0
|
vite-plugin-pages: ^0.29.0
|
||||||
|
@ -44,21 +50,26 @@ importers:
|
||||||
'@prisma/client': 4.13.0_prisma@4.13.0
|
'@prisma/client': 4.13.0_prisma@4.13.0
|
||||||
'@trpc/client': 10.20.0_@trpc+server@10.20.0
|
'@trpc/client': 10.20.0_@trpc+server@10.20.0
|
||||||
'@trpc/server': 10.20.0
|
'@trpc/server': 10.20.0
|
||||||
|
'@types/cookie-parser': 1.4.3
|
||||||
'@vueuse/core': 10.0.2_vue@3.2.47
|
'@vueuse/core': 10.0.2_vue@3.2.47
|
||||||
'@vueuse/integrations': 10.0.2_vue@3.2.47
|
'@vueuse/integrations': 10.0.2_vue@3.2.47
|
||||||
bufferutil: 4.0.7
|
bufferutil: 4.0.7
|
||||||
|
cookie: 0.5.0
|
||||||
|
cookie-parser: 1.4.6
|
||||||
eventemitter3: 5.0.0
|
eventemitter3: 5.0.0
|
||||||
express: 4.18.2
|
express: 4.18.2
|
||||||
immer: 10.0.1
|
immer: 10.0.1
|
||||||
listhen: 1.0.4
|
listhen: 1.0.4
|
||||||
lodash-es: 4.17.21
|
lodash-es: 4.17.21
|
||||||
nanoid: 4.0.2
|
nanoid: 4.0.2
|
||||||
|
pinia: 2.0.34_hmuptsblhheur2tugfgucj7gc4
|
||||||
vue: 3.2.47
|
vue: 3.2.47
|
||||||
vue-router: 4.1.6_vue@3.2.47
|
vue-router: 4.1.6_vue@3.2.47
|
||||||
ws: 8.13.0_bufferutil@4.0.7
|
ws: 8.13.0_bufferutil@4.0.7
|
||||||
zod: 3.21.4
|
zod: 3.21.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@iconify-json/ph': 1.1.5
|
'@iconify-json/ph': 1.1.5
|
||||||
|
'@types/cookie': 0.5.1
|
||||||
'@types/express': 4.17.17
|
'@types/express': 4.17.17
|
||||||
'@types/lodash-es': 4.17.7
|
'@types/lodash-es': 4.17.7
|
||||||
'@types/node': 18.13.0
|
'@types/node': 18.13.0
|
||||||
|
@ -67,6 +78,7 @@ importers:
|
||||||
prisma: 4.13.0
|
prisma: 4.13.0
|
||||||
sass: 1.62.0
|
sass: 1.62.0
|
||||||
tsx: 3.12.6
|
tsx: 3.12.6
|
||||||
|
typescript: 4.9.5
|
||||||
unplugin-icons: 0.16.1
|
unplugin-icons: 0.16.1
|
||||||
vite: 4.2.2_3wfpih6r6pq57j5negv3imilwi
|
vite: 4.2.2_3wfpih6r6pq57j5negv3imilwi
|
||||||
vite-plugin-pages: 0.29.0_vite@4.2.2
|
vite-plugin-pages: 0.29.0_vite@4.2.2
|
||||||
|
@ -750,12 +762,20 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.35
|
'@types/connect': 3.4.35
|
||||||
'@types/node': 18.13.0
|
'@types/node': 18.13.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/connect/3.4.35:
|
/@types/connect/3.4.35:
|
||||||
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
|
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.13.0
|
'@types/node': 18.13.0
|
||||||
|
|
||||||
|
/@types/cookie-parser/1.4.3:
|
||||||
|
resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==}
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 4.17.17
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@types/cookie/0.5.1:
|
||||||
|
resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/debug/4.1.7:
|
/@types/debug/4.1.7:
|
||||||
|
@ -770,7 +790,6 @@ packages:
|
||||||
'@types/node': 18.13.0
|
'@types/node': 18.13.0
|
||||||
'@types/qs': 6.9.7
|
'@types/qs': 6.9.7
|
||||||
'@types/range-parser': 1.2.4
|
'@types/range-parser': 1.2.4
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/express/4.17.17:
|
/@types/express/4.17.17:
|
||||||
resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==}
|
resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==}
|
||||||
|
@ -779,7 +798,6 @@ packages:
|
||||||
'@types/express-serve-static-core': 4.17.33
|
'@types/express-serve-static-core': 4.17.33
|
||||||
'@types/qs': 6.9.7
|
'@types/qs': 6.9.7
|
||||||
'@types/serve-static': 1.15.0
|
'@types/serve-static': 1.15.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/lodash-es/4.17.6:
|
/@types/lodash-es/4.17.6:
|
||||||
resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==}
|
resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==}
|
||||||
|
@ -799,7 +817,6 @@ packages:
|
||||||
|
|
||||||
/@types/mime/3.0.1:
|
/@types/mime/3.0.1:
|
||||||
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/ms/0.7.31:
|
/@types/ms/0.7.31:
|
||||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||||
|
@ -807,22 +824,18 @@ packages:
|
||||||
|
|
||||||
/@types/node/18.13.0:
|
/@types/node/18.13.0:
|
||||||
resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==}
|
resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/qs/6.9.7:
|
/@types/qs/6.9.7:
|
||||||
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
|
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/range-parser/1.2.4:
|
/@types/range-parser/1.2.4:
|
||||||
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
|
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/serve-static/1.15.0:
|
/@types/serve-static/1.15.0:
|
||||||
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
|
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime': 3.0.1
|
'@types/mime': 3.0.1
|
||||||
'@types/node': 18.13.0
|
'@types/node': 18.13.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/web-bluetooth/0.0.16:
|
/@types/web-bluetooth/0.0.16:
|
||||||
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
||||||
|
@ -1268,10 +1281,23 @@ packages:
|
||||||
resolution: {integrity: sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==}
|
resolution: {integrity: sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cookie-parser/1.4.6:
|
||||||
|
resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.4.1
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cookie-signature/1.0.6:
|
/cookie-signature/1.0.6:
|
||||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cookie/0.4.1:
|
||||||
|
resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cookie/0.5.0:
|
/cookie/0.5.0:
|
||||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
@ -2413,6 +2439,24 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/pinia/2.0.34_hmuptsblhheur2tugfgucj7gc4:
|
||||||
|
resolution: {integrity: sha512-cgOoGUiyqX0SSgX8XelK9+Ri4XA2/YyNtgjogwfzIx1g7iZTaZPxm7/bZYMCLU2qHRiHhxG7SuQO0eBacFNc2Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.4.0
|
||||||
|
typescript: '>=4.4.4'
|
||||||
|
vue: ^2.6.14 || ^3.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 6.5.0
|
||||||
|
typescript: 4.9.5
|
||||||
|
vue: 3.2.47
|
||||||
|
vue-demi: 0.14.0_vue@3.2.47
|
||||||
|
dev: false
|
||||||
|
|
||||||
/postcss-import/14.1.0_postcss@8.4.21:
|
/postcss-import/14.1.0_postcss@8.4.21:
|
||||||
resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
|
resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
@ -2783,6 +2827,11 @@ packages:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/typescript/4.9.5:
|
||||||
|
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
|
||||||
|
engines: {node: '>=4.2.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
/ufo/1.0.1:
|
/ufo/1.0.1:
|
||||||
resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==}
|
resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
|
@ -14,7 +14,7 @@ model Game {
|
||||||
actions GameAction[]
|
actions GameAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Player {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
token String @unique
|
token String @unique
|
||||||
|
@ -27,10 +27,10 @@ model GameAction {
|
||||||
|
|
||||||
index Int
|
index Int
|
||||||
gameId String
|
gameId String
|
||||||
game Game @relation(references: [id], fields: [gameId])
|
game Game @relation(references: [id], fields: [gameId], onDelete: Cascade)
|
||||||
|
|
||||||
playerId String? // null → the server
|
playerId String? // null → the server or a deleted user
|
||||||
player Player? @relation(references: [id], fields: [playerId])
|
player User? @relation(references: [id], fields: [playerId], onDelete: SetNull)
|
||||||
|
|
||||||
data String
|
data String
|
||||||
|
|
||||||
|
|
32
src/App.vue
32
src/App.vue
|
@ -1,9 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-gray-900 h-100vh w-100vw">
|
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10">
|
||||||
<div :class="$style.noise"/>
|
<div :class="$style.noise"/>
|
||||||
<div :class="$style.vignette"/>
|
<div :class="$style.vignette"/>
|
||||||
<div class="relative z-1">
|
<div class="relative max-w-1200px mx-auto">
|
||||||
<slot/>
|
<button v-if="!game.isActive" @click="game.join('')">
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
<button v-else-if="game.state.phase === 'pre-start'" @click="game.start()">
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<Game v-else/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -33,6 +39,7 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
|
@ -60,5 +67,24 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { trpcClient } from "./trpc"
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { useGame } from "./clientGame"
|
||||||
|
import Game from "./components/Game.vue"
|
||||||
|
import { useAuth } from "./auth"
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
trpcClient.getSelf.query()
|
||||||
|
.then(({ user }) => {
|
||||||
|
if (user === null) return trpcClient.loginAsGuest.mutate()
|
||||||
|
auth.authenticatedUser = user
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
isLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
</script>
|
</script>
|
||||||
|
|
12
src/auth.ts
Normal file
12
src/auth.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
|
||||||
|
export const useAuth = defineStore("auth", () => {
|
||||||
|
const authenticatedUser = ref<{ id: string; name: string } | null>(null)
|
||||||
|
const requiredUser = computed(() => authenticatedUser.value!)
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticatedUser,
|
||||||
|
requiredUser
|
||||||
|
}
|
||||||
|
})
|
47
src/clientGame.ts
Normal file
47
src/clientGame.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { EventBusKey, useEventBus } from "@vueuse/core"
|
||||||
|
import type { GameAction } from "./shared/game/gameActions"
|
||||||
|
import { reactive, readonly, ref } from "vue"
|
||||||
|
import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state"
|
||||||
|
import { trpcClient } from "./trpc"
|
||||||
|
|
||||||
|
const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
|
||||||
|
const useGameActionsBus = () => useEventBus(gameActionsBusKey)
|
||||||
|
|
||||||
|
export const useGameActionNotification = (listener: (action: GameAction) => unknown) => {
|
||||||
|
const bus = useGameActionsBus()
|
||||||
|
bus.on(listener)
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: () => bus.off(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGame = defineStore("game", () => {
|
||||||
|
const isActive = ref(false)
|
||||||
|
const state = ref<GameState>(getUninitializedGameState())
|
||||||
|
const actions = reactive<GameAction[]>([])
|
||||||
|
|
||||||
|
const actionsBus = useGameActionsBus()
|
||||||
|
actionsBus.on(action => {
|
||||||
|
actions.push(action)
|
||||||
|
state.value = produceNewState(state.value, action)
|
||||||
|
console.log(`⏩ ${action.type}`, action)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: readonly(isActive),
|
||||||
|
state: readonly(state),
|
||||||
|
actions: readonly(actions),
|
||||||
|
join(code: string) {
|
||||||
|
trpcClient.join.subscribe({ code: "game" }, {
|
||||||
|
onData: actionsBus.emit
|
||||||
|
})
|
||||||
|
|
||||||
|
isActive.value = true
|
||||||
|
},
|
||||||
|
start: () => trpcClient.game.start.mutate(),
|
||||||
|
hit: () => trpcClient.game.hit.mutate(),
|
||||||
|
stay: () => trpcClient.game.stay.mutate()
|
||||||
|
}
|
||||||
|
})
|
60
src/components/BigButton.vue
Normal file
60
src/components/BigButton.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="py-5 px-15 font-fat text-5xl rounded-md shadow-lg text-center"
|
||||||
|
:class="$style.root"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<slot/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: url("../assets/noise.png") repeat;
|
||||||
|
opacity: 20%;
|
||||||
|
|
||||||
|
transition: 200ms ease opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@apply bg-gradient-to-b from-dark-600 to-dark-900;
|
||||||
|
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: 500ms ease opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled):hover::before {
|
||||||
|
opacity: 60%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
disabled: Boolean
|
||||||
|
})
|
||||||
|
</script>
|
43
src/components/Card.vue
Normal file
43
src/components/Card.vue
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br w-35 h-45" :class="$style.root">
|
||||||
|
<div class="relative flex flex-col items-center justify-center h-full">
|
||||||
|
<div class="absolute top-2 right-2 bg-dark-800 rounded-full flex gap-2 items-center px-2">
|
||||||
|
<div v-for="tag in tags" :key="tag.label" :title="tag.label">
|
||||||
|
<component :is="tag.icon" class="relative top-0.75"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: url("../assets/noise.png") repeat;
|
||||||
|
opacity: 10%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FunctionalComponent, PropType } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tags: {
|
||||||
|
type: Array as PropType<Array<{
|
||||||
|
label: string
|
||||||
|
icon: FunctionalComponent
|
||||||
|
}>>,
|
||||||
|
default: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
55
src/components/Game.vue
Normal file
55
src/components/Game.vue
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-14">
|
||||||
|
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
|
||||||
|
<div
|
||||||
|
class="flex gap-5 justify-end items-center transform transition ease duration-500"
|
||||||
|
:class="game.state.phase === 'end' ? 'translate-y-4 opacity-0' : ''"
|
||||||
|
>
|
||||||
|
<div class="font-fat opacity-20 text-3xl pr-2">
|
||||||
|
<template v-if="isBust">You are bust.</template>
|
||||||
|
</div>
|
||||||
|
<BigButton
|
||||||
|
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
|
||||||
|
:disabled="!isYourTurn || isBust"
|
||||||
|
@click="game.hit()"
|
||||||
|
>
|
||||||
|
Hit
|
||||||
|
</BigButton>
|
||||||
|
<BigButton
|
||||||
|
class="bg-gradient-to-br from-blue-800 to-blue-900 uppercase"
|
||||||
|
:disabled="!isYourTurn"
|
||||||
|
@click="game.stay()"
|
||||||
|
>
|
||||||
|
Stay
|
||||||
|
</BigButton>
|
||||||
|
</div>
|
||||||
|
<GameEndModal/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGame } from "../clientGame"
|
||||||
|
import PlayerCards from "./PlayerCards.vue"
|
||||||
|
import BigButton from "./BigButton.vue"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useAuth } from "../auth"
|
||||||
|
import { getNumberCardsSum } from "../shared/game/state"
|
||||||
|
import GameEndModal from "./GameEndModal.vue"
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
const auth = useAuth()
|
||||||
|
const isYourTurn = computed(() => game.state.activePlayerId === auth.requiredUser.id)
|
||||||
|
const isBust = computed(() => getNumberCardsSum(game.state.players.find(p => p.id === auth.requiredUser.id)!.numberCards) > game.state.targetSum)
|
||||||
|
|
||||||
|
const reorderedPlayerIds = computed(() => {
|
||||||
|
const ids = game.state.players.map(p => p.id)
|
||||||
|
const selfIndex = ids.findIndex(id => id === auth.requiredUser.id)
|
||||||
|
let before = ids.slice(0, selfIndex)
|
||||||
|
let rest = ids.slice(selfIndex)
|
||||||
|
return [...rest, ...before]
|
||||||
|
})
|
||||||
|
</script>
|
51
src/components/GameEndModal.vue
Normal file
51
src/components/GameEndModal.vue
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<template>
|
||||||
|
<Modal :is-active="isActive" @close-request="isActive = false">
|
||||||
|
<div v-if="game.state.phase === 'end'" class="p-10 text-center">
|
||||||
|
<div class="h-11 transform text-10xl -translate-y-35 opacity-90">
|
||||||
|
<CrownIcon v-if="singleWinner !== null"/>
|
||||||
|
<HandshakeIcon v-else/>
|
||||||
|
</div>
|
||||||
|
<div class="font-fat text-6xl">
|
||||||
|
{{ singleWinner?.name ?? "Draw" }}
|
||||||
|
</div>
|
||||||
|
<div class="text-3xl pt-5 font-bold">
|
||||||
|
<template v-if="singleWinner !== null">
|
||||||
|
wins with
|
||||||
|
<span class="text-green-500">{{ getNumberCardsSum(singleWinner.numberCards) }}</span>
|
||||||
|
point{{ getNumberCardsSum(singleWinner.numberCards) === 1 ? "" : "s" }}.
|
||||||
|
</template>
|
||||||
|
<template>
|
||||||
|
between {{ naturallyJoinEnumeration(game.state.winnerIds.map(id => game.state.players.find(p => p.id === id).name)) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { useGame, useGameActionNotification } from "../clientGame"
|
||||||
|
import Modal from "./Modal.vue"
|
||||||
|
import CrownIcon from "virtual:icons/ph/crown-simple-duotone"
|
||||||
|
import HandshakeIcon from "virtual:icons/ph/handshake-duotone"
|
||||||
|
import { getNumberCardsSum } from "../shared/game/state"
|
||||||
|
import { naturallyJoinEnumeration } from "../shared/util"
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
const isActive = ref(false)
|
||||||
|
|
||||||
|
const singleWinner = computed(() => {
|
||||||
|
const winnerIds = game.state.winnerIds
|
||||||
|
return winnerIds?.length === 1 ? game.state.players.find(p => p.id === winnerIds[0]) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
useGameActionNotification(action => {
|
||||||
|
if (action.type === "end") {
|
||||||
|
isActive.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
32
src/components/Modal.vue
Normal file
32
src/components/Modal.vue
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed top-0 left-0 w-100vw h-100vh backdrop-filter bg-black transition p-10 flex items-center justify-center"
|
||||||
|
:class="isActive ? 'bg-opacity-30 backdrop-blur-5' : 'pointer-events-none bg-opacity-0'"
|
||||||
|
@click.passive.self="emit('close-request')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="transform transition rounded-lg max-h-full max-w-full shadow-lg bg-dark-500"
|
||||||
|
:class="isActive ? 'opacity-100' : 'opacity-0 scale-90'"
|
||||||
|
>
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onKeyDown } from "@vueuse/core"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isActive: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(["close-request"])
|
||||||
|
|
||||||
|
onKeyDown("Escape", () => {
|
||||||
|
if (props.isActive) emit("close-request")
|
||||||
|
}, { passive: true })
|
||||||
|
</script>
|
45
src/components/NumberCard.vue
Normal file
45
src/components/NumberCard.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
:class="isUnknown ? 'bg-gradient-to-br from-gray-700 to-gray-800' : 'bg-gradient-to-br from-yellow-600 to-yellow-900'"
|
||||||
|
:tags="tags"
|
||||||
|
>
|
||||||
|
<div class="font-fat text-white relative bottom-1 text-shadow-xl" :class="[isUnknown ? 'text-8xl' : 'text-7xl']">
|
||||||
|
{{ isUnknown ? "?" : number }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Card from "./Card.vue"
|
||||||
|
import { computed, FunctionalComponent } from "vue"
|
||||||
|
import EyeSlashIcon from "virtual:icons/ph/eye-slash"
|
||||||
|
import { useGame } from "../clientGame"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
number: number
|
||||||
|
isCovert: boolean
|
||||||
|
isOwn: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
|
||||||
|
const isUnknown = computed(() => props.number === 0)
|
||||||
|
const tags = computed(() => {
|
||||||
|
const tags: Array<{ icon: FunctionalComponent; label: string }> = []
|
||||||
|
|
||||||
|
if (props.isCovert && !isUnknown.value) {
|
||||||
|
tags.push({
|
||||||
|
icon: EyeSlashIcon,
|
||||||
|
label: game.state.phase === 'end' && !props.isOwn
|
||||||
|
? "You couldn’t see the number on this card during the game"
|
||||||
|
: "The other players can’t see the number on this card"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
})
|
||||||
|
</script>
|
50
src/components/PlayerCards.vue
Normal file
50
src/components/PlayerCards.vue
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="font-fat text-2xl relative">
|
||||||
|
{{ playerState.name }}
|
||||||
|
<template v-if="isYou">(You)</template>
|
||||||
|
<span
|
||||||
|
class="text-3xl absolute -top-0.5 -left-13 transition"
|
||||||
|
:class="game.state.winnerIds?.includes(playerState.id) ? '' : 'opacity-0'"
|
||||||
|
>
|
||||||
|
<CrownIcon/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-5 w-full flex-wrap">
|
||||||
|
<NumberCard
|
||||||
|
v-for="(card, index) in playerState.numberCards"
|
||||||
|
:key="[index, card.number]"
|
||||||
|
:number="card.number"
|
||||||
|
:is-covert="card.isCovert"
|
||||||
|
:is-own="isYou"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="font-fat opacity-30 text-3xl">
|
||||||
|
{{ getNumberCardsSum(playerState.numberCards) }}
|
||||||
|
<template v-if="!isYou && game.state.phase !== 'end' && playerState.numberCards.some(c => c.isCovert)">+ ?</template>
|
||||||
|
/ {{ game.state.targetSum }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGame } from "../clientGame"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import NumberCard from "./NumberCard.vue"
|
||||||
|
import { useAuth } from "../auth"
|
||||||
|
import { getNumberCardsSum } from "../shared/game/state"
|
||||||
|
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
playerId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
const auth = useAuth()
|
||||||
|
const playerState = computed(() => game.state.players.find(p => p.id === props.playerId)!)
|
||||||
|
const isYou = computed(() => playerState.value.id === auth.requiredUser.id)
|
||||||
|
</script>
|
10
src/index.ts
10
src/index.ts
|
@ -3,18 +3,14 @@ import { createApp } from "vue"
|
||||||
import { createRouter, createWebHistory } from "vue-router"
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
import routes from "virtual:generated-pages"
|
import routes from "virtual:generated-pages"
|
||||||
|
import { createPinia } from "pinia"
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routes: [
|
routes,
|
||||||
...routes,
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
redirect: "/d"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
history: createWebHistory()
|
history: createWebHistory()
|
||||||
})
|
})
|
||||||
|
|
||||||
createApp(App)
|
createApp(App)
|
||||||
.use(router)
|
.use(router)
|
||||||
|
.use(createPinia())
|
||||||
.mount("#app")
|
.mount("#app")
|
||||||
|
|
193
src/server/activeGame.ts
Normal file
193
src/server/activeGame.ts
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
import EventEmitter from "eventemitter3"
|
||||||
|
import type { GameAction } from "../shared/game/gameActions"
|
||||||
|
import { prismaClient } from "./prisma"
|
||||||
|
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "../shared/game/state"
|
||||||
|
import type { RemoveKey } from "../shared/RemoveKey"
|
||||||
|
import { mapValues, random } from "lodash-es"
|
||||||
|
import type { DeepReadonly } from "vue"
|
||||||
|
|
||||||
|
const activeGamesByCode = new Map<string, ActiveGame>()
|
||||||
|
|
||||||
|
export function getActiveGameByCode(code: string): ActiveGame | null {
|
||||||
|
return activeGamesByCode.get(code) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveGameOfPlayer(userId: string): ActiveGame | null {
|
||||||
|
for (const game of activeGamesByCode.values()) {
|
||||||
|
if (game.lobbyPlayerIds.has(userId)) return game
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_REDACTION = Symbol()
|
||||||
|
|
||||||
|
function redactGameAction<T extends GameAction>(action: T, receiverId: string): T | typeof NO_REDACTION {
|
||||||
|
switch (action.type) {
|
||||||
|
case "deal-number":
|
||||||
|
if (action.isCovert) {
|
||||||
|
if (action.toPlayerId === receiverId) return action
|
||||||
|
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
number: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NO_REDACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Events {
|
||||||
|
broadcast_action: [GameAction]
|
||||||
|
private_action: [string, GameAction]
|
||||||
|
destroyed: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveGame extends EventEmitter<Events> {
|
||||||
|
private nextIndex = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
private _state = getUninitializedGameState()
|
||||||
|
get state(): DeepReadonly<GameState> {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
lobbyPlayerIds = new Set<string>()
|
||||||
|
|
||||||
|
constructor(public id: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
const players = await prismaClient.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: [...this.lobbyPlayerIds]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.addAction({
|
||||||
|
type: "start",
|
||||||
|
targetSum: 21,
|
||||||
|
players
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
await this.dealNumberTo(player.id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hit() {
|
||||||
|
const playerId = this.state.activePlayerId
|
||||||
|
|
||||||
|
await this.addAction({
|
||||||
|
type: "hit",
|
||||||
|
initiatingPlayerId: playerId
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.dealNumberTo(playerId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stay() {
|
||||||
|
const playerId = this.state.activePlayerId
|
||||||
|
|
||||||
|
await this.addAction({
|
||||||
|
type: "stay",
|
||||||
|
initiatingPlayerId: playerId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.state.players.every(p => p.stayed)) {
|
||||||
|
await this.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async end() {
|
||||||
|
let closestSafeValue = 0
|
||||||
|
let closestSafePlayerIds: string[] = []
|
||||||
|
let closestBustValue = Number.POSITIVE_INFINITY
|
||||||
|
let closestBustPlayerIds: string[] = []
|
||||||
|
|
||||||
|
for (const player of this.state.players) {
|
||||||
|
const sum = getNumberCardsSum(player.numberCards)
|
||||||
|
if (sum <= this.state.targetSum) {
|
||||||
|
if (sum >= closestSafeValue) {
|
||||||
|
if (sum > closestSafeValue) closestSafePlayerIds = []
|
||||||
|
closestSafeValue = sum
|
||||||
|
closestSafePlayerIds.push(player.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sum <= closestBustValue) {
|
||||||
|
if (sum < closestBustValue) closestBustPlayerIds = []
|
||||||
|
closestBustValue = sum
|
||||||
|
closestBustPlayerIds.push(player.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerIds = closestSafePlayerIds.length !== 0 ? closestSafePlayerIds : closestBustPlayerIds
|
||||||
|
|
||||||
|
await this.addAction({
|
||||||
|
type: "end",
|
||||||
|
winnerIds,
|
||||||
|
cardsByPlayerId: Object.fromEntries(this.state.players.map(player => [player.id, player.numberCards.map(c => c.number)]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dealNumberTo(playerId: string, isCovert: boolean) {
|
||||||
|
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
|
||||||
|
|
||||||
|
await this.addAction({
|
||||||
|
type: "deal-number",
|
||||||
|
number,
|
||||||
|
toPlayerId: playerId,
|
||||||
|
isCovert
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addAction<T extends RemoveKey<GameAction, "index">>(action: T): Promise<T & { index: number }> {
|
||||||
|
const fullAction = {
|
||||||
|
...action,
|
||||||
|
index: this.nextIndex++
|
||||||
|
} as T & { index: number }
|
||||||
|
|
||||||
|
await prismaClient.gameAction.create({
|
||||||
|
data: {
|
||||||
|
gameId: this.id,
|
||||||
|
playerId: action.initiatingPlayerId,
|
||||||
|
index: fullAction.index,
|
||||||
|
data: JSON.stringify(fullAction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// noinspection JSDeprecatedSymbols
|
||||||
|
this._state = produceNewState(this._state, fullAction)
|
||||||
|
|
||||||
|
for (const player of this.state.players) {
|
||||||
|
const redactedAction = redactGameAction(fullAction, player.id)
|
||||||
|
|
||||||
|
if (redactedAction === NO_REDACTION) {
|
||||||
|
this.emit("broadcast_action", fullAction)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
this.emit("private_action", player.id, redactedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullAction
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroy() {
|
||||||
|
activeGamesByCode.delete(this.id)
|
||||||
|
this.emit("destroyed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeGamesByCode.set("game", new ActiveGame("game"))
|
|
@ -1,29 +0,0 @@
|
||||||
import EventEmitter from "eventemitter3"
|
|
||||||
import type { GameAction } from "../shared/game/gameActions"
|
|
||||||
|
|
||||||
const gamesById = new Map<string, Game>()
|
|
||||||
|
|
||||||
export function getGameById(id: string): Game {
|
|
||||||
return gamesById.get(id)!
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Events {
|
|
||||||
public_action: [GameAction]
|
|
||||||
private_action: [string, GameAction]
|
|
||||||
destroyed: []
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Game extends EventEmitter<Events> {
|
|
||||||
constructor(public id: string) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroy() {
|
|
||||||
gamesById.delete(this.id)
|
|
||||||
this.emit("destroyed")
|
|
||||||
}
|
|
||||||
}
|
|
1
src/server/isDev.ts
Normal file
1
src/server/isDev.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const isDev = process.env.NODE_ENV === "development"
|
|
@ -6,26 +6,41 @@ import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/ad
|
||||||
import { applyWSSHandler } from "@trpc/server/adapters/ws"
|
import { applyWSSHandler } from "@trpc/server/adapters/ws"
|
||||||
import { seedDatabase } from "./seed"
|
import { seedDatabase } from "./seed"
|
||||||
import { createContext } from "./trpc/base"
|
import { createContext } from "./trpc/base"
|
||||||
|
import cookieParser from "cookie-parser"
|
||||||
|
import { parse as parseCookie } from "cookie"
|
||||||
|
import { isDev } from "./isDev"
|
||||||
|
|
||||||
const expressApp = createExpressApp()
|
const expressApp = createExpressApp()
|
||||||
|
expressApp.use(cookieParser())
|
||||||
|
|
||||||
expressApp.use("/trpc", createTrpcMiddleware({
|
expressApp.use("/trpc", createTrpcMiddleware({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: ({ req }) => createContext(req.headers.authorization)
|
createContext: ({ req, res }) => createContext(req.cookies.token ?? null, res),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await seedDatabase()
|
await seedDatabase()
|
||||||
const { server } = await listen(expressApp)
|
const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" })
|
const wss = new WebSocketServer({ server, path: "/ws" })
|
||||||
const wssTrpcHandler = applyWSSHandler({
|
const wssTrpcHandler = applyWSSHandler({
|
||||||
wss,
|
wss,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: ({ req }) => createContext(req.headers.authorization)
|
createContext: ({ req }) => {
|
||||||
|
const cookies = parseCookie(req.headers.cookie ?? "")
|
||||||
|
return createContext(cookies.token ?? null, undefined)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
const stop = () => {
|
||||||
console.log("Received SIGTERM")
|
console.log("Received stop signal")
|
||||||
|
server.close()
|
||||||
wssTrpcHandler.broadcastReconnectNotification()
|
wssTrpcHandler.broadcastReconnectNotification()
|
||||||
wss.close()
|
wss.close(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", stop)
|
||||||
|
process.on("SIGINT", stop)
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
console.log("exit")
|
||||||
})
|
})
|
|
@ -1,28 +1,27 @@
|
||||||
import { prismaClient } from "./prisma"
|
import { prismaClient } from "./prisma"
|
||||||
|
|
||||||
export async function seedDatabase() {
|
export async function seedDatabase() {
|
||||||
if (await prismaClient.player.count() === 0) {
|
if (await prismaClient.user.count() === 0) {
|
||||||
await prismaClient.player.create({
|
await prismaClient.user.create({
|
||||||
data: {
|
data: {
|
||||||
name: "max",
|
name: "Guest 1",
|
||||||
token: "max"
|
token: "guest1"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await prismaClient.player.create({
|
await prismaClient.user.create({
|
||||||
data: {
|
data: {
|
||||||
name: "moritz",
|
name: "Guest 2",
|
||||||
token: "moritz"
|
token: "guest2"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await prismaClient.game.count() === 0) {
|
await prismaClient.game.deleteMany({})
|
||||||
await prismaClient.game.create({
|
await prismaClient.game.create({
|
||||||
data: {
|
data: {
|
||||||
id: "game",
|
id: "game",
|
||||||
lobbyCodeIfActive: "game"
|
lobbyCodeIfActive: "game"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,22 +1,36 @@
|
||||||
import { initTRPC } from "@trpc/server"
|
import { createInputMiddleware, initTRPC, TRPCError } from "@trpc/server"
|
||||||
import { prismaClient } from "../prisma"
|
import { prismaClient } from "../prisma"
|
||||||
|
import type { Response } from "express"
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
playerId: string
|
userId: string | null
|
||||||
|
res?: Response
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContext(authorizationHeader: string | undefined): Promise<Context> {
|
export async function createContext(authenticationToken: string | null, res: Response | undefined): Promise<Context> {
|
||||||
const token = authorizationHeader?.slice(7) ?? null // "Bearer " → 7 characters
|
let userId = null
|
||||||
|
|
||||||
if (token !== null && token.length > 0) {
|
if (authenticationToken !== null && authenticationToken.length > 0) {
|
||||||
const player = await prismaClient.player.findUnique({ where: { token }, select: { id: true } })
|
const user = await prismaClient.user.findUnique({ where: { token: authenticationToken }, select: { id: true } })
|
||||||
|
if (user !== null) userId = user.id
|
||||||
if (player !== null) return {
|
|
||||||
playerId: player.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Invalid token")
|
return {
|
||||||
|
userId,
|
||||||
|
res
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const t = initTRPC.context<Context>().create()
|
export const t = initTRPC.context<Context>().create()
|
||||||
|
|
||||||
|
export const requireAuthentication = t.middleware(({ ctx, next }) => {
|
||||||
|
let userId = ctx.userId
|
||||||
|
if (userId === null) throw new TRPCError({ code: "UNAUTHORIZED" })
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
39
src/server/trpc/game.ts
Normal file
39
src/server/trpc/game.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { requireAuthentication, t } from "./base"
|
||||||
|
import { getActiveGameOfPlayer } from "../activeGame"
|
||||||
|
import { getNumberCardsSum } from "../../shared/game/state"
|
||||||
|
|
||||||
|
const gameProcedure = t.procedure
|
||||||
|
.use(requireAuthentication)
|
||||||
|
.use(t.middleware(async ({ ctx, next }) => {
|
||||||
|
const game = getActiveGameOfPlayer(ctx.userId!)
|
||||||
|
|
||||||
|
if (game === null) throw new Error("The player is not in an active game")
|
||||||
|
return await next({
|
||||||
|
ctx: {
|
||||||
|
game
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const gameRouter = t.router({
|
||||||
|
start: gameProcedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
if (ctx.game.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${ctx.game.state.phase}`)
|
||||||
|
ctx.game.start()
|
||||||
|
}),
|
||||||
|
|
||||||
|
hit: gameProcedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the player’s turn")
|
||||||
|
if (getNumberCardsSum(ctx.game.state.players.find(p => p.id === ctx.userId)!.numberCards) > ctx.game.state.targetSum)
|
||||||
|
throw new Error("The player cannot hit when bust")
|
||||||
|
|
||||||
|
await ctx.game.hit()
|
||||||
|
}),
|
||||||
|
|
||||||
|
stay: gameProcedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
if (ctx.game.state.activePlayerId !== ctx.userId) throw new Error("It is not the player’s turn")
|
||||||
|
await ctx.game.stay()
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,35 +1,62 @@
|
||||||
import { t } from "./base"
|
import { requireAuthentication, t } from "./base"
|
||||||
import { prismaClient } from "../prisma"
|
import { prismaClient } from "../prisma"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { observable } from "@trpc/server/observable"
|
import { observable } from "@trpc/server/observable"
|
||||||
import type { GameAction } from "../../shared/game/gameActions"
|
import type { GameAction } from "../../shared/game/gameActions"
|
||||||
import { getGameById } from "../game"
|
import { isDev } from "../isDev"
|
||||||
|
import { getActiveGameByCode } from "../activeGame"
|
||||||
|
import { gameRouter } from "./game"
|
||||||
|
|
||||||
|
let lastGuestWasOne = false
|
||||||
|
|
||||||
export const appRouter = t.router({
|
export const appRouter = t.router({
|
||||||
start: t.procedure
|
game: gameRouter,
|
||||||
.mutation(async ({ ctx }) => {
|
|
||||||
|
|
||||||
|
loginAsGuest: t.procedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
let token = lastGuestWasOne ? "guest1" : "guest2"
|
||||||
|
lastGuestWasOne = !lastGuestWasOne
|
||||||
|
ctx.res!.cookie("token", token, {
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !isDev,
|
||||||
|
sameSite: "strict"
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSelf: t.procedure
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
if (ctx.userId === null) return { user: null }
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: await prismaClient.user.findUnique({ where: { id: ctx.userId } })
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
join: t.procedure
|
join: t.procedure
|
||||||
|
.use(requireAuthentication)
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
gameId: z.string().nonempty()
|
code: z.string().nonempty()
|
||||||
}))
|
}))
|
||||||
.subscription(({ input, ctx }) => {
|
.subscription(({ input, ctx }) => {
|
||||||
const game = getGameById(input.gameId)
|
const game = getActiveGameByCode(input.code)
|
||||||
|
if (game === null) throw new Error("There is no game with this code")
|
||||||
|
|
||||||
return observable(emit => {
|
game.lobbyPlayerIds.add(ctx.userId)
|
||||||
const handlePublicAction = (action: GameAction) => emit.next(action)
|
|
||||||
|
return observable<GameAction>(emit => {
|
||||||
|
const handleBroadcastAction = (action: GameAction) => emit.next(action)
|
||||||
|
|
||||||
const handlePrivateAction = (playerId: string, action: GameAction) => {
|
const handlePrivateAction = (playerId: string, action: GameAction) => {
|
||||||
if (playerId === ctx.playerId) emit.next(action)
|
if (playerId === ctx.userId) emit.next(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
game.on("public_action", handlePublicAction)
|
game.on("broadcast_action", handleBroadcastAction)
|
||||||
game.on("private_action", handlePrivateAction)
|
game.on("private_action", handlePrivateAction)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
game.off("public_action", handlePublicAction)
|
game.lobbyPlayerIds.delete(ctx.userId)
|
||||||
|
game.off("broadcast_action", handleBroadcastAction)
|
||||||
game.off("private_action", handlePrivateAction)
|
game.off("private_action", handlePrivateAction)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
5
src/shared/RemoveKey.ts
Normal file
5
src/shared/RemoveKey.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// I don’t understand why, but Omit (built-in) breaks discriminated unions. This type does not.
|
||||||
|
// See https://github.com/microsoft/TypeScript/issues/31501#issuecomment-1079728677
|
||||||
|
export type RemoveKey<T, K extends string> = {
|
||||||
|
[P in keyof T as Exclude<P, K>]: T[P];
|
||||||
|
}
|
|
@ -5,7 +5,9 @@ const specialCardTypesObject = {
|
||||||
"increase-target-by-2": true,
|
"increase-target-by-2": true,
|
||||||
"next-round-covert": true,
|
"next-round-covert": true,
|
||||||
"double-draw": true,
|
"double-draw": true,
|
||||||
"add-2-opponent": true
|
"add-2-opponent": true,
|
||||||
|
"get-1": true,
|
||||||
|
"get-11": true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const specialCardTypes = Object.keys(specialCardTypesObject)
|
export const specialCardTypes = Object.keys(specialCardTypesObject)
|
||||||
|
|
|
@ -4,7 +4,7 @@ type PlayerAction = {
|
||||||
initiatingPlayerId: string
|
initiatingPlayerId: string
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: "draw"
|
type: "hit"
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "stay"
|
type: "stay"
|
||||||
|
@ -15,22 +15,35 @@ type PlayerAction = {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerAction =
|
type ServerAction = {
|
||||||
|
initiatingPlayerId?: never
|
||||||
|
} & (
|
||||||
| {
|
| {
|
||||||
type: "start"
|
type: "start"
|
||||||
numberCardsByPlayerId: Record<string, number[]> // if redacted: only contains the player themself
|
|
||||||
targetSum: number
|
targetSum: number
|
||||||
|
players: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "deal-number"
|
type: "deal-number"
|
||||||
toPlayerId: string
|
toPlayerId: string
|
||||||
number: number
|
number: number
|
||||||
|
isCovert: boolean
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "deal-special"
|
type: "deal-special"
|
||||||
toPlayerId: string
|
toPlayerId: string
|
||||||
cardType: SpecialCardType
|
cardType: SpecialCardType
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "end"
|
||||||
|
winnerIds: string[]
|
||||||
|
cardsByPlayerId: Record<string, number[]> // without redactions
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
export type GameAction = {
|
export type GameAction = {
|
||||||
index: number
|
index: number
|
||||||
|
|
|
@ -2,53 +2,94 @@ import type { SpecialCardType } from "./cards"
|
||||||
import type { GameAction } from "./gameActions"
|
import type { GameAction } from "./gameActions"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { specialCardTypes } from "./cards"
|
import { specialCardTypes } from "./cards"
|
||||||
|
import { cloneDeep, tail, without } from "lodash-es"
|
||||||
|
|
||||||
type SpecialCardCountByType = Record<SpecialCardType, number>
|
type SpecialCardCountByType = Record<SpecialCardType, number>
|
||||||
|
|
||||||
|
export interface GameStateNumberCard {
|
||||||
|
number: number
|
||||||
|
isCovert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNumberCardsSum = (cards: Readonly<GameStateNumberCard[]>) => cards.reduce((acc, card) => acc + card.number, 0)
|
||||||
|
|
||||||
export interface GameStatePlayer {
|
export interface GameStatePlayer {
|
||||||
id: string
|
id: string
|
||||||
numberCards: number[]
|
name: string
|
||||||
|
numberCards: GameStateNumberCard[]
|
||||||
specialCardCountByType: SpecialCardCountByType
|
specialCardCountByType: SpecialCardCountByType
|
||||||
stayed: boolean
|
stayed: boolean
|
||||||
nextRoundCovert: boolean
|
nextRoundCovert: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
|
phase: "pre-start" | "running" | "end"
|
||||||
players: GameStatePlayer[]
|
players: GameStatePlayer[]
|
||||||
activeSpecialCardCountByType: SpecialCardCountByType
|
activeSpecialCardCountByType: SpecialCardCountByType
|
||||||
activePlayerId: string
|
activePlayerId: string
|
||||||
targetSum: number
|
targetSum: number
|
||||||
|
numberCardsStack: number[] // if redacted: contains more cards than there actually are
|
||||||
|
actualNumberCardsStackSize: number // the length of numberCardsStack, smaller if redacted
|
||||||
|
winnerIds: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType
|
const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType
|
||||||
|
|
||||||
const UNINITIALIZED_GAME_STATE: GameState = {
|
const UNINITIALIZED_GAME_STATE: GameState = {
|
||||||
|
phase: "pre-start",
|
||||||
players: [],
|
players: [],
|
||||||
activePlayerId: "",
|
activePlayerId: "",
|
||||||
targetSum: 0,
|
targetSum: 0,
|
||||||
activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS
|
activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
|
||||||
|
numberCardsStack: [],
|
||||||
|
actualNumberCardsStackSize: 0,
|
||||||
|
winnerIds: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getUninitializedGameState = () => cloneDeep(UNINITIALIZED_GAME_STATE)
|
||||||
|
export const getFullNumbersCardStack = () => tail([...Array(12).keys()])
|
||||||
|
|
||||||
export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => {
|
export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => {
|
||||||
|
const activateNextPlayer = () => {
|
||||||
|
const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId)
|
||||||
|
state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id
|
||||||
|
}
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "start":
|
case "start":
|
||||||
state.players = Object.entries(action.numberCardsByPlayerId).map(([playerId, numberCards]): GameStatePlayer => ({
|
state.players = action.players.map((player): GameStatePlayer => ({
|
||||||
id: playerId,
|
id: player.id,
|
||||||
numberCards,
|
name: player.name,
|
||||||
|
numberCards: [],
|
||||||
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
|
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
|
||||||
stayed: false,
|
stayed: false,
|
||||||
nextRoundCovert: false
|
nextRoundCovert: false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
state.phase = "running"
|
||||||
state.activePlayerId = state.players[0].id
|
state.activePlayerId = state.players[0].id
|
||||||
state.targetSum = action.targetSum
|
state.targetSum = action.targetSum
|
||||||
state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS
|
state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS
|
||||||
|
state.numberCardsStack = getFullNumbersCardStack()
|
||||||
|
state.actualNumberCardsStackSize = state.numberCardsStack.length
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case "deal-number":
|
case "deal-number":
|
||||||
const p1 = state.players.find(p => p.id === action.toPlayerId)!
|
const p1 = state.players.find(p => p.id === action.toPlayerId)!
|
||||||
p1.numberCards.push(action.number)
|
p1.numberCards.push({
|
||||||
|
number: action.number,
|
||||||
|
isCovert: action.isCovert
|
||||||
|
})
|
||||||
|
|
||||||
|
state.numberCardsStack = without(state.numberCardsStack, action.number)
|
||||||
|
state.actualNumberCardsStackSize--
|
||||||
|
|
||||||
|
if (state.actualNumberCardsStackSize === 0) {
|
||||||
|
state.numberCardsStack = getFullNumbersCardStack()
|
||||||
|
state.actualNumberCardsStackSize = state.numberCardsStack.length
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case "deal-special":
|
case "deal-special":
|
||||||
|
@ -56,18 +97,29 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
p2.specialCardCountByType[action.cardType]++
|
p2.specialCardCountByType[action.cardType]++
|
||||||
break
|
break
|
||||||
|
|
||||||
case "draw":
|
case "hit":
|
||||||
case "stay":
|
activateNextPlayer()
|
||||||
const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId)
|
const p4 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
||||||
|
p4.stayed = false
|
||||||
|
break
|
||||||
|
|
||||||
// activate the next player
|
case "stay":
|
||||||
state.activePlayerId = state.players[(activePlayerIndex + 1) % state.players.length].id
|
activateNextPlayer()
|
||||||
|
const p5 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
||||||
|
p5.stayed = true
|
||||||
break
|
break
|
||||||
|
|
||||||
case "use-special":
|
case "use-special":
|
||||||
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
||||||
applySpecialCardUsage(state, action.cardType, p3)
|
applySpecialCardUsage(state, action.cardType, p3)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
state.phase = "end"
|
||||||
|
state.winnerIds = action.winnerIds
|
||||||
|
for (let player of state.players) {
|
||||||
|
player.numberCards = action.cardsByPlayerId[player.id].map((number, index) => ({ number, isCovert: player.numberCards[index].isCovert }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
6
src/shared/util.ts
Normal file
6
src/shared/util.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const naturallyJoinEnumeration = (enumeration: string[]) => {
|
||||||
|
const start = enumeration.slice(0, -1)
|
||||||
|
if (start.length === 0) return enumeration.join("")
|
||||||
|
const last = enumeration[enumeration.length - 1]
|
||||||
|
return start.join(", ") + " and " + last.toString()
|
||||||
|
}
|
|
@ -14,10 +14,10 @@ export const trpcClient = createTRPCProxyClient<AppRouter>({
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: o => o.type === "subscription",
|
condition: o => o.type === "subscription",
|
||||||
true: wsLink({
|
true: wsLink({
|
||||||
client: wsClient
|
client: wsClient,
|
||||||
}),
|
}),
|
||||||
false: httpLink({
|
false: httpLink({
|
||||||
url: "/trpc",
|
url: "/trpc"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
5
src/types.d.ts
vendored
Normal file
5
src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "*.vue" {
|
||||||
|
import { ConcreteComponent } from "vue"
|
||||||
|
const C: ConcreteComponent
|
||||||
|
export default C
|
||||||
|
}
|
|
@ -18,6 +18,7 @@
|
||||||
"stripInternal": true,
|
"stripInternal": true,
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"types": [
|
"types": [
|
||||||
|
"src/types.d.ts",
|
||||||
"vite/client",
|
"vite/client",
|
||||||
"unplugin-icons/types/vue",
|
"unplugin-icons/types/vue",
|
||||||
"vite-plugin-pages/client"
|
"vite-plugin-pages/client"
|
||||||
|
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
||||||
theme: {
|
theme: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
"normal": ["InterVariable", "sans-serif"],
|
"normal": ["InterVariable", "sans-serif"],
|
||||||
"fat": ["TitanOne", "sans-serif"]
|
"fat": ["Titan One", "sans-serif"]
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
transparent: "transparent",
|
transparent: "transparent",
|
||||||
|
|
Loading…
Add table
Reference in a new issue