Switch to Svelte
This commit is contained in:
20
flake.nix
20
flake.nix
@@ -18,6 +18,19 @@
|
|||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python3;
|
python = pkgs.python3;
|
||||||
|
|
||||||
|
# Build the Svelte frontend
|
||||||
|
frontend = pkgs.buildNpmPackage {
|
||||||
|
pname = "pirats-frontend";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./frontend;
|
||||||
|
npmDepsHash = "sha256-ekYBi0oUjtnsfdig2B0yrmT7KbSc9IB1DVbTCRwYI24=";
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out
|
||||||
|
cp -r dist $out/
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = python.pkgs.buildPythonApplication {
|
default = python.pkgs.buildPythonApplication {
|
||||||
@@ -35,7 +48,6 @@
|
|||||||
fastapi
|
fastapi
|
||||||
sqlmodel
|
sqlmodel
|
||||||
uvicorn
|
uvicorn
|
||||||
jinja2
|
|
||||||
python-multipart
|
python-multipart
|
||||||
httpx
|
httpx
|
||||||
];
|
];
|
||||||
@@ -44,6 +56,11 @@
|
|||||||
pytestCheckHook
|
pytestCheckHook
|
||||||
];
|
];
|
||||||
|
|
||||||
|
preBuild = ''
|
||||||
|
mkdir -p src/pirats/static
|
||||||
|
cp -r ${frontend}/dist/* src/pirats/static/
|
||||||
|
'';
|
||||||
|
|
||||||
pythonImportsCheck = [ "pirats" ];
|
pythonImportsCheck = [ "pirats" ];
|
||||||
};
|
};
|
||||||
pirats = self.packages.${system}.default;
|
pirats = self.packages.${system}.default;
|
||||||
@@ -59,6 +76,7 @@
|
|||||||
inputsFrom = [ self.packages.${system}.default ];
|
inputsFrom = [ self.packages.${system}.default ];
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
pkgs.python3.pkgs.pytest
|
pkgs.python3.pkgs.pytest
|
||||||
|
pkgs.nodejs
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
43
frontend/README.md
Normal file
43
frontend/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Svelte + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `checkJs` in the JS template?**
|
||||||
|
|
||||||
|
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.js
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
frontend/jsconfig.json
Normal file
33
frontend/jsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
/**
|
||||||
|
* svelte-preprocess cannot figure out whether you have
|
||||||
|
* a value or a type, so tell TypeScript to enforce using
|
||||||
|
* `import type` instead of `import` for Types.
|
||||||
|
*/
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* To have warnings / errors of the Svelte compiler at the
|
||||||
|
* correct position, enable source maps by default.
|
||||||
|
*/
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable this if you'd like to use dynamic types.
|
||||||
|
*/
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Use global.d.ts instead of compilerOptions.types
|
||||||
|
* to avoid limiting type declarations.
|
||||||
|
*/
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
1186
frontend/package-lock.json
generated
Normal file
1186
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"svelte-spa-router": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
18
frontend/src/App.svelte
Normal file
18
frontend/src/App.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script>
|
||||||
|
import Router from 'svelte-spa-router';
|
||||||
|
import Home from './pages/Home.svelte';
|
||||||
|
import Join from './pages/Join.svelte';
|
||||||
|
import Dashboard from './pages/Dashboard.svelte';
|
||||||
|
import Admin from './pages/Admin.svelte';
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'/': Home,
|
||||||
|
'/game/:id/join': Join,
|
||||||
|
'/game/:id/player/:pid': Dashboard,
|
||||||
|
'/game/:id/admin': Admin,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<Router {routes} />
|
||||||
|
</main>
|
||||||
11
frontend/src/app.css
Normal file
11
frontend/src/app.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import './assets/css/style.css';
|
||||||
|
@import './assets/css/core.css';
|
||||||
|
@import './assets/css/components.css';
|
||||||
|
@import './assets/css/welcome.css';
|
||||||
|
@import './assets/css/lobby.css';
|
||||||
|
@import './assets/css/character.css';
|
||||||
|
@import './assets/css/scene-setup.css';
|
||||||
|
@import './assets/css/scene-play.css';
|
||||||
|
@import './assets/css/card.css';
|
||||||
|
@import './assets/css/upkeep.css';
|
||||||
|
@import './assets/css/admin.css';
|
||||||
28
frontend/src/assets/css/admin.css
Normal file
28
frontend/src/assets/css/admin.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* --- Admin Panel --- */
|
||||||
|
.admin-players-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-player-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info-basic {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
244
frontend/src/assets/css/card.css
Normal file
244
frontend/src/assets/css/card.css
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/* Card Widget (Mini) */
|
||||||
|
.card-mini {
|
||||||
|
width: 32px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--text-muted);
|
||||||
|
background: var(--bg-dark);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.base-card {
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
background: rgba(212,175,55,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.success {
|
||||||
|
border-color: var(--neon-emerald);
|
||||||
|
color: var(--neon-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini.failure {
|
||||||
|
border-color: var(--red-suit);
|
||||||
|
color: var(--red-suit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini .val {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini .suit {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mini .owner {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Widget (Medium) */
|
||||||
|
.card-medium {
|
||||||
|
width: 75px;
|
||||||
|
height: 112px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1.5px solid var(--glass-border);
|
||||||
|
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.3rem;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner .val {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-corner .suit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.suit-c, .card-medium.suit-s {
|
||||||
|
border-color: rgba(226, 232, 240, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.suit-h, .card-medium.suit-d {
|
||||||
|
border-color: rgba(255, 42, 95, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.joker-card {
|
||||||
|
border-color: var(--gold);
|
||||||
|
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.suit-c .val, .card-medium.suit-c .suit,
|
||||||
|
.card-medium.suit-s .val, .card-medium.suit-s .suit {
|
||||||
|
color: var(--black-suit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.suit-h .val, .card-medium.suit-h .suit,
|
||||||
|
.card-medium.suit-d .val, .card-medium.suit-d .suit {
|
||||||
|
color: var(--red-suit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium .card-center {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); }
|
||||||
|
.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); }
|
||||||
|
|
||||||
|
/* Card Widget (Large) */
|
||||||
|
.card-large {
|
||||||
|
width: 110px;
|
||||||
|
height: 165px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--glass-border);
|
||||||
|
background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large:hover {
|
||||||
|
transform: translateY(-8px) scale(1.05);
|
||||||
|
border-color: var(--gold);
|
||||||
|
box-shadow: 0 10px 25px rgba(212, 175, 55, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.suit-c, .card-large.suit-s {
|
||||||
|
border-color: rgba(226, 232, 240, 0.15);
|
||||||
|
}
|
||||||
|
.card-large.suit-c:hover, .card-large.suit-s:hover {
|
||||||
|
border-color: var(--black-suit);
|
||||||
|
box-shadow: 0 10px 25px var(--black-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.suit-h, .card-large.suit-d {
|
||||||
|
border-color: rgba(255, 42, 95, 0.15);
|
||||||
|
}
|
||||||
|
.card-large.suit-h:hover, .card-large.suit-d:hover {
|
||||||
|
border-color: var(--red-suit);
|
||||||
|
box-shadow: 0 10px 25px var(--red-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.joker-card {
|
||||||
|
border-color: var(--gold);
|
||||||
|
background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .card-corner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .card-corner .val {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .card-corner .suit {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.suit-c .val, .card-large.suit-c .suit,
|
||||||
|
.card-large.suit-s .val, .card-large.suit-s .suit {
|
||||||
|
color: var(--black-suit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.suit-h .val, .card-large.suit-h .suit,
|
||||||
|
.card-large.suit-d .val, .card-large.suit-d .suit {
|
||||||
|
color: var(--red-suit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large .card-center {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.suit-c .card-center, .card-large.suit-s .card-center { color: var(--black-suit); }
|
||||||
|
.card-large.suit-h .card-center, .card-large.suit-d .card-center { color: var(--red-suit); }
|
||||||
|
|
||||||
|
.card-large .card-tech-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-tag {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.joker-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag and Drop Interface Styles */
|
||||||
|
.card-large[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-large.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
border-style: dashed;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-item.drag-over {
|
||||||
|
border-color: var(--gold) !important;
|
||||||
|
background: rgba(212, 175, 55, 0.1) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important;
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
442
frontend/src/assets/css/character.css
Normal file
442
frontend/src/assets/css/character.css
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/* --- Character Creation & Sheet Grid --- */
|
||||||
|
.creation-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.creation-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-line {
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-box {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-box h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-box {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-box {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-item {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid var(--neon-cyan);
|
||||||
|
background: rgba(0, 242, 254, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-item label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-techniques .tech-chip {
|
||||||
|
background: rgba(212,175,55,0.05);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-chip {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-assignment-pill {
|
||||||
|
background: rgba(0, 242, 254, 0.05);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rank {
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
font-weight: 900;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Character Sheet layout --- */
|
||||||
|
.character-sheet-details {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-group {
|
||||||
|
background: rgba(0,0,0,0.15);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 2px solid var(--gold-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-group h4 {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-list-sheet {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-list-sheet li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-top-small {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Visual & Tactile Technique Assignment --- */
|
||||||
|
.face-tech-drag-form {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-techniques-container {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: inset 0 0 15px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-techniques-container h4 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-help {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.techniques-drag-pool {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 50px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip {
|
||||||
|
background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip.selected {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 242, 254, 0.5);
|
||||||
|
background: rgba(0, 242, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-tech-chip.assigned-hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-icon {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slots Grid */
|
||||||
|
.face-cards-slots-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.face-cards-slots-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 220px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
min-height: 280px;
|
||||||
|
background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%);
|
||||||
|
border: 2px dashed rgba(212, 175, 55, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot:hover {
|
||||||
|
border-color: rgba(212, 175, 55, 0.7);
|
||||||
|
box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.drag-over {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
border-style: solid;
|
||||||
|
background: rgba(0, 242, 254, 0.05);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 242, 254, 0.2);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--gold);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bg-letter {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: rgba(255, 255, 255, 0.025);
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-bg-letter {
|
||||||
|
color: rgba(212, 175, 55, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-slot-header, .card-slot-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-slot-header,
|
||||||
|
.face-card-slot.has-assignment .card-slot-footer {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-slot-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .card-title {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-placeholder {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot:hover .drop-zone-placeholder {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-tech-content {
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .drop-zone-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-card-slot.has-assignment .assigned-tech-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-tech-chip {
|
||||||
|
background: rgba(212, 175, 55, 0.08);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
word-break: break-word;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-tech-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c0392b;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-tech-btn:hover {
|
||||||
|
background: #e74c3c;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.9); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
301
frontend/src/assets/css/components.css
Normal file
301
frontend/src/assets/css/components.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/* --- UI Panels & Glassmorphism --- */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
border-color: rgba(212, 175, 55, 0.35);
|
||||||
|
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-ocean-light);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--gold);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Forms & Controls --- */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(7, 11, 18, 0.8);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(7, 11, 18, 0.8);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row .input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-dark) 100%);
|
||||||
|
color: var(--text-dark);
|
||||||
|
box-shadow: 0 4px 15px var(--gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-deep {
|
||||||
|
background: linear-gradient(135deg, #152238 0%, #0d1726 100%);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-deep:hover:not(:disabled) {
|
||||||
|
background: var(--neon-cyan);
|
||||||
|
color: var(--text-dark);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 242, 254, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #7a1c1c 0%, #c0392b 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 4px 15px rgba(192, 57, 43, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(192, 57, 43, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gold {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
box-shadow: 0 0 10px rgba(212,175,55,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gold:hover:not(:disabled) {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
box-shadow: 0 0 20px var(--gold-glow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-effect:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Event Log --- */
|
||||||
|
.event-log-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-fab:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 20px;
|
||||||
|
width: 350px;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
height: 400px;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 999;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-panel.hidden {
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-msg {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
237
frontend/src/assets/css/core.css
Normal file
237
frontend/src/assets/css/core.css
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/* --- Variables & Tokens --- */
|
||||||
|
:root {
|
||||||
|
--bg-dark: #070b12;
|
||||||
|
--bg-ocean: #0d1726;
|
||||||
|
--bg-ocean-light: #16243b;
|
||||||
|
|
||||||
|
--gold: #d4af37;
|
||||||
|
--gold-glow: rgba(212, 175, 55, 0.4);
|
||||||
|
--gold-dark: #aa841c;
|
||||||
|
|
||||||
|
--neon-cyan: #00f2fe;
|
||||||
|
--neon-emerald: #00ff87;
|
||||||
|
|
||||||
|
--red-suit: #ff2a5f;
|
||||||
|
--red-glow: rgba(255, 42, 95, 0.3);
|
||||||
|
--black-suit: #e2e8f0;
|
||||||
|
--black-glow: rgba(226, 232, 240, 0.2);
|
||||||
|
|
||||||
|
--glass-bg: rgba(13, 23, 38, 0.7);
|
||||||
|
--glass-bg-hover: rgba(22, 36, 59, 0.85);
|
||||||
|
--glass-border: rgba(212, 175, 55, 0.2);
|
||||||
|
--glass-border-focus: rgba(0, 242, 254, 0.4);
|
||||||
|
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--text-dark: #0f172a;
|
||||||
|
|
||||||
|
--font-heading: 'Cinzel', serif;
|
||||||
|
--font-body: 'Outfit', sans-serif;
|
||||||
|
|
||||||
|
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Base Reset & Setup --- */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: radial-gradient(circle at 50% 50%, var(--bg-ocean) 0%, var(--bg-dark) 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--gold-dark);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Layout Components --- */
|
||||||
|
.app-header {
|
||||||
|
background: rgba(7, 11, 18, 0.95);
|
||||||
|
border-bottom: 2px solid var(--gold);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container h1 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: linear-gradient(135deg, var(--gold) 30%, #fff 70%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 10px var(--gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.math-magic {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||||
|
animation: pulse-glow 3s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pill {
|
||||||
|
background: rgba(0, 242, 254, 0.1);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pill .bullet {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--neon-emerald);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px var(--neon-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-pill {
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loader / Spinner widgets --- */
|
||||||
|
.loader-container {
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid rgba(0, 242, 254, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--neon-cyan);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--gold);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Alert styles --- */
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: rgba(192, 57, 43, 0.2);
|
||||||
|
border: 1px solid #c0392b;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Animation keyframes --- */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0% {
|
||||||
|
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow: 0 0 15px var(--neon-cyan), 0 0 30px var(--neon-cyan), 0 0 40px var(--neon-cyan);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Utilities --- */
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.margin-top { margin-top: 1.5rem; }
|
||||||
|
.gold-text { color: var(--gold); }
|
||||||
|
.center-block { margin: 1rem auto; max-width: 500px; }
|
||||||
|
.inline-form { display: flex; gap: 0.5rem; width: 100%; }
|
||||||
|
.inline-group { display: flex; gap: 0.5rem; width: 100%; }
|
||||||
|
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
||||||
|
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
|
||||||
|
.btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* --- Ghost World Styles --- */
|
||||||
|
.ghost-world {
|
||||||
|
filter: grayscale(95%) brightness(0.85) sepia(15%) contrast(1.05);
|
||||||
|
transition: filter 1.2s ease-in-out;
|
||||||
|
}
|
||||||
106
frontend/src/assets/css/lobby.css
Normal file
106
frontend/src/assets/css/lobby.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/* --- Lobby / Hold View --- */
|
||||||
|
.lobby-view {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-view h2 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--gold);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-status-box {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip.current-user {
|
||||||
|
background: rgba(0, 242, 254, 0.15);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 10px rgba(0,242,254,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip.voted-chip {
|
||||||
|
border-color: var(--neon-emerald);
|
||||||
|
background-color: rgba(0,255,135,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chip.not-voted-chip {
|
||||||
|
border-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-badge {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-box {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item h4 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #05080e;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-input.admin-input {
|
||||||
|
border-color: var(--gold-dark);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-item {
|
||||||
|
border-top: 1px dashed rgba(212, 175, 55, 0.3);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
277
frontend/src/assets/css/scene-play.css
Normal file
277
frontend/src/assets/css/scene-play.css
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/* --- Scene Play Board Layout --- */
|
||||||
|
.scene-view-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.scene-view-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-counter {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Active Obstacle Item Display --- */
|
||||||
|
.obstacles-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-item {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: rgba(7, 11, 18, 0.4);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.obstacle-item {
|
||||||
|
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.obstacle-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color theme mappings for suits */
|
||||||
|
.suit-c { border-left: 4px solid var(--black-suit); }
|
||||||
|
.suit-s { border-left: 4px solid var(--black-suit); }
|
||||||
|
.suit-h { border-left: 4px solid var(--red-suit); }
|
||||||
|
.suit-d { border-left: 4px solid var(--red-suit); }
|
||||||
|
|
||||||
|
.obstacle-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-badge {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit-c .suit-badge, .suit-s .suit-badge { color: var(--black-suit); }
|
||||||
|
.suit-h .suit-badge, .suit-d .suit-badge { color: var(--red-suit); }
|
||||||
|
|
||||||
|
.card-code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-details h4 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-details .desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-value-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(212, 175, 55, 0.04);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-successes-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 255, 135, 0.04);
|
||||||
|
border: 1px dashed rgba(0, 255, 135, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacle-successes-display .val-number {
|
||||||
|
color: var(--neon-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.val-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val-number {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.played-column {
|
||||||
|
grid-column: span 4;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.played-column {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.played-column h5 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-cards-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Challenges Section --- */
|
||||||
|
.challenges-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item h4 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-item .desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applied-obstacles h5 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applied-obs-row {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-info .symbol {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-card-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-xsmall {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 140px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Deep Control panel --- */
|
||||||
|
.deep-text {
|
||||||
|
color: var(--neon-cyan) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-divider {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, rgba(0, 242, 254, 0), rgba(0, 242, 254, 0.4), rgba(0, 242, 254, 0));
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obstacles-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
83
frontend/src/assets/css/scene-setup.css
Normal file
83
frontend/src/assets/css/scene-setup.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/* --- Scene Setup / Role Choose --- */
|
||||||
|
.setup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.setup-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 2px solid rgba(255,255,255,0.1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn.pirat-btn:hover, .role-btn.pirat-btn.active {
|
||||||
|
border-color: var(--neon-emerald);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: rgba(0,255,135,0.06);
|
||||||
|
box-shadow: 0 0 15px rgba(0,255,135,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn.deep-btn:hover, .role-btn.deep-btn.active {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: rgba(0,242,254,0.06);
|
||||||
|
box-shadow: 0 0 15px rgba(0,242,254,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-badge {
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.deep {
|
||||||
|
background: rgba(0, 242, 254, 0.15);
|
||||||
|
border: 2px solid var(--neon-cyan);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 15px rgba(0,242,254,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.pirat {
|
||||||
|
background: rgba(0, 255, 135, 0.15);
|
||||||
|
border: 2px solid var(--neon-emerald);
|
||||||
|
color: var(--neon-emerald);
|
||||||
|
box-shadow: 0 0 15px rgba(0,255,135,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-chips {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-chips .player-chip {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-chip {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
background: rgba(0, 242, 254, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pirat-chip {
|
||||||
|
border-color: var(--neon-emerald);
|
||||||
|
background: rgba(0, 255, 135, 0.03);
|
||||||
|
}
|
||||||
18
frontend/src/assets/css/style.css
Normal file
18
frontend/src/assets/css/style.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
RATS WITH GATS DESIGN SYSTEM & STYLESHEET
|
||||||
|
A weathered math-magic pirate aesthetic: deep-ocean dark mode, glassmorphism,
|
||||||
|
neon math runes, gold accents, and animated card widgets.
|
||||||
|
|
||||||
|
This file imports modular stylesheets divided by page phase and components.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import url("core.css");
|
||||||
|
@import url("components.css");
|
||||||
|
@import url("card.css");
|
||||||
|
@import url("welcome.css");
|
||||||
|
@import url("lobby.css");
|
||||||
|
@import url("character.css");
|
||||||
|
@import url("scene-setup.css");
|
||||||
|
@import url("scene-play.css");
|
||||||
|
@import url("upkeep.css");
|
||||||
|
@import url("admin.css");
|
||||||
100
frontend/src/assets/css/upkeep.css
Normal file
100
frontend/src/assets/css/upkeep.css
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/* --- Between Scenes Upkeep --- */
|
||||||
|
.between-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.between-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting-card, .deep-rest-card {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voted-confirmation-box {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--neon-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--neon-emerald);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranks-ledger {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranks-ledger li {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deep-badge {
|
||||||
|
background: rgba(0, 242, 254, 0.15);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pirat-badge {
|
||||||
|
background: rgba(0, 255, 135, 0.15);
|
||||||
|
border: 1px solid var(--neon-emerald);
|
||||||
|
color: var(--neon-emerald);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
|
||||||
|
.upkeep-drag-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upkeep-drag-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-box {
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-box h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-flex {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
align-content: flex-start;
|
||||||
|
min-height: 300px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upkeep-box[ondragover]:hover .upkeep-flex {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
}
|
||||||
50
frontend/src/assets/css/welcome.css
Normal file
50
frontend/src/assets/css/welcome.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* --- Welcome / Main Menu Screen --- */
|
||||||
|
.welcome-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-hero h2 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-hero .subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
527
frontend/src/components/CharacterCreationPhase.svelte
Normal file
527
frontend/src/components/CharacterCreationPhase.svelte
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { getSuggestion } from '../lib/suggestions';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
let basicDetails = {
|
||||||
|
avatar_look: state.player.avatar_look || '',
|
||||||
|
avatar_smell: state.player.avatar_smell || '',
|
||||||
|
first_words: state.player.first_words || '',
|
||||||
|
good_at_math: state.player.good_at_math || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
let savingBasic = false;
|
||||||
|
let delegating = false;
|
||||||
|
|
||||||
|
async function saveBasicDetails() {
|
||||||
|
savingBasic = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/save-basic`, 'POST', basicDetails);
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
savingBasic = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoDelegate() {
|
||||||
|
delegating = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
delegating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper to get player name
|
||||||
|
function getPlayerName(id) {
|
||||||
|
if (!id) return "None";
|
||||||
|
const p = state.players.find(x => x.id === id);
|
||||||
|
return p ? p.name : "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Techniques
|
||||||
|
let tech1 = '', tech2 = '', tech3 = '';
|
||||||
|
let savingTechs = false;
|
||||||
|
|
||||||
|
async function submitTechniques() {
|
||||||
|
savingTechs = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
|
tech1, tech2, tech3
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
savingTechs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face Techniques
|
||||||
|
let jackTech = '', queenTech = '', kingTech = '';
|
||||||
|
let assigningFace = false;
|
||||||
|
let faceError = '';
|
||||||
|
|
||||||
|
async function assignFaceTechniques() {
|
||||||
|
assigningFace = true;
|
||||||
|
faceError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/assign-face-techniques`, 'POST', {
|
||||||
|
jack: jackTech,
|
||||||
|
queen: queenTech,
|
||||||
|
king: kingTech
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
faceError = e.message;
|
||||||
|
} finally {
|
||||||
|
assigningFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit delegated answers
|
||||||
|
let answerLike = '', answerHate = '';
|
||||||
|
async function submitDelegatedAnswer(type, targetId, answer) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check completion status
|
||||||
|
$: createdTechniques = state.player.created_techniques ? JSON.parse(state.player.created_techniques) : [];
|
||||||
|
$: swappedTechniques = state.player.swapped_techniques ? JSON.parse(state.player.swapped_techniques) : [];
|
||||||
|
|
||||||
|
$: basicComplete = !!(state.player.avatar_look && state.player.avatar_smell && state.player.first_words && state.player.good_at_math);
|
||||||
|
$: delegateComplete = !!(state.player.other_like_from_player_id && state.player.other_hate_from_player_id);
|
||||||
|
$: techniquesComplete = !!(state.player.tech_jack && state.player.tech_queen && state.player.tech_king);
|
||||||
|
|
||||||
|
// Dev Mode
|
||||||
|
let devMode = false;
|
||||||
|
let autoFilling = false;
|
||||||
|
|
||||||
|
async function autoFillAll() {
|
||||||
|
autoFilling = true;
|
||||||
|
try {
|
||||||
|
// 1. Basic details
|
||||||
|
if (!basicComplete) {
|
||||||
|
basicDetails = {
|
||||||
|
avatar_look: 'A scruffy rat',
|
||||||
|
avatar_smell: 'Like old cheese',
|
||||||
|
first_words: 'Give me the loot!',
|
||||||
|
good_at_math: 'No'
|
||||||
|
};
|
||||||
|
await saveBasicDetails();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delegate
|
||||||
|
if (!delegateComplete && state.players.length >= 2) {
|
||||||
|
await autoDelegate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Answer questions (might need a quick refresh to get the questions, but we can do our best with current state)
|
||||||
|
// Wait, state needs to be re-fetched to get the assigned questions. We can just fill what's currently in inbox.
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
||||||
|
await submitDelegatedAnswer('like', p.id, 'They are sneaky');
|
||||||
|
}
|
||||||
|
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
|
||||||
|
await submitDelegatedAnswer('hate', p.id, 'They snore loud');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Techniques
|
||||||
|
if (createdTechniques.length === 0) {
|
||||||
|
tech1 = 'Pocket Sand';
|
||||||
|
tech2 = 'Tail Whip';
|
||||||
|
tech3 = 'Cheese Decoy';
|
||||||
|
await submitTechniques();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
||||||
|
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
||||||
|
if (!techniquesComplete && swappedTechniques.length === 3) {
|
||||||
|
jackTech = swappedTechniques[0];
|
||||||
|
queenTech = swappedTechniques[1];
|
||||||
|
kingTech = swappedTechniques[2];
|
||||||
|
await assignFaceTechniques();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
autoFilling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="character-creation-view">
|
||||||
|
<div class="view-header text-center">
|
||||||
|
<h2>Character Sheet Creation</h2>
|
||||||
|
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
||||||
|
<div class="flex justify-center items-center gap-2 text-sm text-gray-500 mt-2">
|
||||||
|
<input type="checkbox" id="devModeToggle" bind:checked={devMode} class="accent-gold"/>
|
||||||
|
<label for="devModeToggle">Dev Mode</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if devMode}
|
||||||
|
<div class="bg-dark-900 border border-gold p-4 rounded-lg flex justify-between items-center mb-6 max-w-4xl mx-auto">
|
||||||
|
<span class="text-gold font-bold">🛠 Dev Mode Tools</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={autoFillAll}
|
||||||
|
disabled={autoFilling}>
|
||||||
|
{autoFilling ? 'Auto-Filling...' : 'Auto-Fill Available Fields'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- MAIN PANEL: Grid for Layout -->
|
||||||
|
<div class="creation-grid max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<!-- SECTION 1: Rat Records (Basic Info) -->
|
||||||
|
<div class="card glass-panel section-basic">
|
||||||
|
<h3>I. Basic Rat Records {basicComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
|
{#if basicComplete}
|
||||||
|
<!-- Saved View -->
|
||||||
|
<div class="read-only-sheet">
|
||||||
|
<div class="record-line"><strong>Look:</strong> {state.player.avatar_look}</div>
|
||||||
|
<div class="record-line"><strong>Smell:</strong> {state.player.avatar_smell}</div>
|
||||||
|
<div class="record-line"><strong>First Words:</strong> "{state.player.first_words}"</div>
|
||||||
|
<div class="record-line"><strong>Good at Math?</strong> {state.player.good_at_math}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Form View -->
|
||||||
|
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_words">What were your first words after transforming?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
||||||
|
<div class="card glass-panel section-delegations">
|
||||||
|
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
|
||||||
|
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||||
|
{#if !delegateComplete}
|
||||||
|
<button on:click={autoDelegate} disabled={delegating || state.players.length < 2} class="btn btn-secondary btn-small">
|
||||||
|
🎲 Auto-Assign Tasks
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
|
||||||
|
|
||||||
|
<div class="delegation-list">
|
||||||
|
{#if delegateComplete}
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat LIKE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{state.player.other_like ? `"${state.player.other_like}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(state.player.other_like_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="delegation-box glass-panel">
|
||||||
|
<h4>What does another rat HATE about you?</h4>
|
||||||
|
<div class="answer-box">
|
||||||
|
{state.player.other_hate ? `"${state.player.other_hate}"` : '(Waiting for answer...)'}
|
||||||
|
</div>
|
||||||
|
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-sm text-gray-400">Randomly assign crewmates to write your relationships by clicking "Auto-Assign Tasks".</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 3: Answer Delegations for Others -->
|
||||||
|
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
|
||||||
|
<h3>III. Your Inbox (Task List)</h3>
|
||||||
|
<p class="section-desc">Answer these questions for your crewmates. Be creative and have fun with it!</p>
|
||||||
|
<div class="inbox-list">
|
||||||
|
{#each state.players as p}
|
||||||
|
{#if p.other_like_from_player_id === state.player.id && !p.other_like}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>What do you LIKE about {p.name}?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={answerLike} placeholder="e.g. They always share their cheese"/>
|
||||||
|
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('like', p.id, answerLike)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if p.other_hate_from_player_id === state.player.id && !p.other_hate}
|
||||||
|
<div class="inbox-item">
|
||||||
|
<label>What do you HATE about {p.name}?</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" class="input-field" bind:value={answerHate} placeholder="e.g. They snore too loudly"/>
|
||||||
|
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('hate', p.id, answerHate)}>Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if state.players.filter(p => (p.other_like_from_player_id === state.player.id && !p.other_like) || (p.other_hate_from_player_id === state.player.id && !p.other_hate)).length === 0}
|
||||||
|
<p class="inbox-empty-message text-gray-400 italic">No pending tasks! Wait for other players to assign you tasks.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 4: Techniques -->
|
||||||
|
<div class="card glass-panel section-techniques" id="techniques-card" style="grid-column: span 2;">
|
||||||
|
<h3>IV. Secret Techniques {techniquesComplete ? '✅' : '❌'}</h3>
|
||||||
|
|
||||||
|
{#if createdTechniques.length === 0}
|
||||||
|
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
||||||
|
<form on:submit|preventDefault={submitTechniques} id="tech-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech1">Technique #1:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech2">Technique #2:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tech3">Technique #3:</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques')}>Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||||
|
</form>
|
||||||
|
{:else if createdTechniques.length === 3 && swappedTechniques.length < 3}
|
||||||
|
<div class="submitted-techniques text-center mt-4">
|
||||||
|
<div class="tech-chip">#1: {createdTechniques[0]}</div>
|
||||||
|
<div class="tech-chip">#2: {createdTechniques[1]}</div>
|
||||||
|
<div class="tech-chip">#3: {createdTechniques[2]}</div>
|
||||||
|
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if !techniquesComplete}
|
||||||
|
{#if swappedTechniques.length === 3}
|
||||||
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<p class="text-gray-300">Drag and drop your assigned techniques onto your Face Cards:</p>
|
||||||
|
|
||||||
|
{#if faceError}
|
||||||
|
<div class="alert alert-danger">{faceError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="available-techniques-container">
|
||||||
|
<h4>Available Swapped Techniques</h4>
|
||||||
|
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
|
||||||
|
<div class="techniques-drag-pool">
|
||||||
|
{#each swappedTechniques as tech}
|
||||||
|
<div
|
||||||
|
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
|
||||||
|
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
|
||||||
|
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
|
||||||
|
>
|
||||||
|
<span class="drag-icon">⋮⋮</span> {tech}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual Card Slots Grid -->
|
||||||
|
<div class="face-cards-slots-grid">
|
||||||
|
|
||||||
|
<!-- JACK SLOT -->
|
||||||
|
<div class="face-card-slot-wrapper">
|
||||||
|
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop={(e) => {
|
||||||
|
const tech = e.dataTransfer.getData('text/plain');
|
||||||
|
if (tech) {
|
||||||
|
if (queenTech === tech) queenTech = '';
|
||||||
|
if (kingTech === tech) kingTech = '';
|
||||||
|
jackTech = tech;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="card-bg-letter">J</div>
|
||||||
|
<div class="card-slot-header">
|
||||||
|
<span class="rank">J</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-body">
|
||||||
|
<div class="card-title">Jack</div>
|
||||||
|
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||||
|
<div class="assigned-tech-content">
|
||||||
|
{#if jackTech}
|
||||||
|
<div class="assigned-tech-chip">
|
||||||
|
{jackTech}
|
||||||
|
<span class="remove-tech-btn" on:click={() => jackTech = ''}>×</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-footer">
|
||||||
|
<span></span>
|
||||||
|
<span class="rank">J</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QUEEN SLOT -->
|
||||||
|
<div class="face-card-slot-wrapper">
|
||||||
|
<div class="face-card-slot {queenTech ? 'has-assignment' : ''}"
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop={(e) => {
|
||||||
|
const tech = e.dataTransfer.getData('text/plain');
|
||||||
|
if (tech) {
|
||||||
|
if (jackTech === tech) jackTech = '';
|
||||||
|
if (kingTech === tech) kingTech = '';
|
||||||
|
queenTech = tech;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="card-bg-letter">Q</div>
|
||||||
|
<div class="card-slot-header">
|
||||||
|
<span class="rank">Q</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-body">
|
||||||
|
<div class="card-title">Queen</div>
|
||||||
|
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||||
|
<div class="assigned-tech-content">
|
||||||
|
{#if queenTech}
|
||||||
|
<div class="assigned-tech-chip">
|
||||||
|
{queenTech}
|
||||||
|
<span class="remove-tech-btn" on:click={() => queenTech = ''}>×</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-footer">
|
||||||
|
<span></span>
|
||||||
|
<span class="rank">Q</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KING SLOT -->
|
||||||
|
<div class="face-card-slot-wrapper">
|
||||||
|
<div class="face-card-slot {kingTech ? 'has-assignment' : ''}"
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop={(e) => {
|
||||||
|
const tech = e.dataTransfer.getData('text/plain');
|
||||||
|
if (tech) {
|
||||||
|
if (jackTech === tech) jackTech = '';
|
||||||
|
if (queenTech === tech) queenTech = '';
|
||||||
|
kingTech = tech;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="card-bg-letter">K</div>
|
||||||
|
<div class="card-slot-header">
|
||||||
|
<span class="rank">K</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-body">
|
||||||
|
<div class="card-title">King</div>
|
||||||
|
<div class="drop-zone-placeholder">Drop Technique Here</div>
|
||||||
|
<div class="assigned-tech-content">
|
||||||
|
{#if kingTech}
|
||||||
|
<div class="assigned-tech-chip">
|
||||||
|
{kingTech}
|
||||||
|
<span class="remove-tech-btn" on:click={() => kingTech = ''}>×</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-slot-footer">
|
||||||
|
<span></span>
|
||||||
|
<span class="rank">K</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-large w-full"
|
||||||
|
disabled={assigningFace || !jackTech || !queenTech || !kingTech}
|
||||||
|
on:click={assignFaceTechniques}
|
||||||
|
>
|
||||||
|
{assigningFace ? 'Assigning...' : 'Assign to Cards'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center italic text-gray-400">
|
||||||
|
Waiting for all players to submit techniques so they can be shuffled...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="assigned-techs text-center mt-4">
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">J</span> {state.player.tech_jack}</div>
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {state.player.tech_queen}</div>
|
||||||
|
<div class="tech-assignment-pill"><span class="card-rank">K</span> {state.player.tech_king}</div>
|
||||||
|
<p class="waiting-box margin-top flex justify-center mt-4">
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Ready! Waiting for other players to finish J/Q/K assignment...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 5: Crew Creation Progress -->
|
||||||
|
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
||||||
|
<h3>V. Crew Creation Status</h3>
|
||||||
|
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
||||||
|
<div class="crew-status-list">
|
||||||
|
<ul class="space-y-2 mt-4">
|
||||||
|
{#each state.players as p}
|
||||||
|
<li class="p-3 bg-dark-800 rounded flex justify-between items-center border-l-4 {p.tech_jack ? 'border-green-500' : 'border-gray-500'}">
|
||||||
|
<span>{p.name}</span>
|
||||||
|
<span class="text-sm">
|
||||||
|
{#if p.tech_jack}
|
||||||
|
<span class="text-green-400">Ready</span>
|
||||||
|
{:else if p.created_techniques && JSON.parse(p.created_techniques).length === 3}
|
||||||
|
<span class="text-yellow-400">Assigning Face Cards</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">Writing Sheet</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
79
frontend/src/components/LobbyPhase.svelte
Normal file
79
frontend/src/components/LobbyPhase.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let starting = false;
|
||||||
|
|
||||||
|
async function startGame() {
|
||||||
|
starting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/lobby/start`, 'POST');
|
||||||
|
// The polling will pick up the phase change automatically
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start game:", err);
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lobby-view text-center">
|
||||||
|
<h2>{state.game.crew_name || "Ship's Hold"} (Lobby)</h2>
|
||||||
|
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||||
|
|
||||||
|
<div class="lobby-status-box glass-panel">
|
||||||
|
<h3>Joined Crew members</h3>
|
||||||
|
<div class="player-chips" id="lobby-player-list">
|
||||||
|
{#each state.players as p}
|
||||||
|
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
||||||
|
<span class="avatar-icon">🐀</span>
|
||||||
|
<span class="name">{p.name}</span>
|
||||||
|
{#if p.is_creator}<span class="creator-badge">Creator</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-box glass-panel">
|
||||||
|
<div class="link-item">
|
||||||
|
<h4>Share Join Link:</h4>
|
||||||
|
<div class="copy-container">
|
||||||
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/join" class="copy-input" id="join-link-copy">
|
||||||
|
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!'); }} class="btn btn-secondary btn-small">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if state.player.is_creator}
|
||||||
|
<div class="link-item admin-link-item">
|
||||||
|
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
|
||||||
|
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
||||||
|
<div class="copy-container">
|
||||||
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
||||||
|
<button on:click={() => { navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!'); }} class="btn btn-gold btn-small">Copy Admin Link</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/#/game/{state.game.id}/admin" class="text-sm text-silver underline hover:text-white">Access Admin Panel directly</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lobby-action">
|
||||||
|
{#if state.player.is_creator}
|
||||||
|
{#if state.players.length >= 1}
|
||||||
|
<button on:click={startGame}
|
||||||
|
disabled={starting}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
{starting ? 'Starting...' : 'Start Character Creation'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="waiting-indicator">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<p>Waiting for Captain to cast the spell...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
503
frontend/src/components/ScenePhase.svelte
Normal file
503
frontend/src/components/ScenePhase.svelte
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let playingCard = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
$: isCaptain = state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.id === state.player.id;
|
||||||
|
|
||||||
|
async function playCard(obstacleId, cardCode) {
|
||||||
|
playingCard = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||||
|
obstacle_id: obstacleId,
|
||||||
|
card_code: cardCode
|
||||||
|
});
|
||||||
|
// Removed alert
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
playingCard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playJoker(obstacleId, cardCode) {
|
||||||
|
playingCard = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-joker`, 'POST', {
|
||||||
|
obstacle_id: obstacleId,
|
||||||
|
card_code: cardCode
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
playingCard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endScene() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearObstacle(obstacleId) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/obstacle/${obstacleId}/clear`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleObjective(targetPlayerId, type) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${targetPlayerId}/objective/toggle`, 'POST', {
|
||||||
|
type: type
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let showEventLog = false;
|
||||||
|
|
||||||
|
let newNameInput = '';
|
||||||
|
async function submitNewName() {
|
||||||
|
if (!newNameInput.trim()) return;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-name`, 'POST', {
|
||||||
|
new_name: newNameInput.trim()
|
||||||
|
});
|
||||||
|
newNameInput = '';
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing card helpers
|
||||||
|
function getCardValue(code) {
|
||||||
|
if (!code) return 0;
|
||||||
|
if (code === 'JOKER') return 0;
|
||||||
|
let rank = code.slice(0, -1);
|
||||||
|
if (['J', 'Q', 'K'].includes(rank)) return 10;
|
||||||
|
if (rank === 'A') return 1;
|
||||||
|
return parseInt(rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardDisplay(code) {
|
||||||
|
if (!code) return "";
|
||||||
|
if (code === 'JOKER') return "🃏 JOKER";
|
||||||
|
let rank = code.slice(0, -1);
|
||||||
|
let suit = code.slice(-1);
|
||||||
|
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
||||||
|
return `${rank}${suitEmoji}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||||
|
<!-- LEFT COLUMN: The Shared Table -->
|
||||||
|
<div class="table-column">
|
||||||
|
|
||||||
|
<!-- Game Deck and Obstacles Roster -->
|
||||||
|
<div class="card glass-panel obstacle-list-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 The Obstacle List</h3>
|
||||||
|
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="obstacles-container" id="scene-obstacles-container">
|
||||||
|
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
||||||
|
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<!-- Captain Badge -->
|
||||||
|
<div>
|
||||||
|
{#if isCaptain}
|
||||||
|
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
🏴☠️ Captain: {state.players.find(p => p.id === state.player.id)?.name} (Rank {state.players.find(p => p.id === state.player.id)?.rank})
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
🏴☠️ Captain: {state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.name || 'None'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Scene Roster Mini-list -->
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
||||||
|
{#each state.players as p}
|
||||||
|
{#if p.role === 'deep'}
|
||||||
|
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
|
🌊 {p.name} (Deep)
|
||||||
|
</span>
|
||||||
|
{:else if p.role === 'pirat'}
|
||||||
|
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
|
||||||
|
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? ' ⭐' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each state.obstacles as obs}
|
||||||
|
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
||||||
|
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
||||||
|
{@const success_count = column_cards.filter(c => c.success).length}
|
||||||
|
{@const is_completed = success_count >= state.players.filter(p => p.role !== 'deep').length}
|
||||||
|
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
|
||||||
|
data-obstacle-id={obs.id}
|
||||||
|
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||||
|
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||||
|
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
|
||||||
|
on:drop={(e) => {
|
||||||
|
if (is_completed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove("drag-over");
|
||||||
|
const cardCode = e.dataTransfer.getData('text/plain');
|
||||||
|
if (cardCode) {
|
||||||
|
if (cardCode === 'JOKER') playJoker(obs.id, cardCode);
|
||||||
|
else playCard(obs.id, cardCode);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
||||||
|
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()} {active_card_code === 'JOKER' ? 'joker-card' : ''}" title="Active Card: {getCardDisplay(active_card_code)}">
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-center">
|
||||||
|
{#if active_card_code === 'JOKER'}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="obstacle-details">
|
||||||
|
<h4>{obs.title}</h4>
|
||||||
|
<p class="desc">{obs.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Value Display -->
|
||||||
|
<div class="obstacle-value-display text-center">
|
||||||
|
<span class="val-label">Current Difficulty</span>
|
||||||
|
<span class="val-number">
|
||||||
|
{#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))}
|
||||||
|
Rank ({state.player.rank})
|
||||||
|
{:else}
|
||||||
|
{obs.current_value}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Successes Tracker -->
|
||||||
|
<div class="obstacle-successes-display text-center">
|
||||||
|
<span class="val-label">Successes</span>
|
||||||
|
<span class="val-number" style="font-size: 1.5rem;">
|
||||||
|
{success_count} / {state.players.filter(p => p.role !== 'deep').length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Played Cards Column -->
|
||||||
|
<div class="played-column">
|
||||||
|
<h5>Card History (Column)</h5>
|
||||||
|
<div class="column-cards-flex">
|
||||||
|
<div class="card-mini base-card">
|
||||||
|
<span class="val">{obs.original_card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
{#each column_cards as pc}
|
||||||
|
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||||
|
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
||||||
|
<span class="val">{pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||||
|
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if is_completed && state.player.role === 'deep'}
|
||||||
|
<div class="text-center" style="margin-top: 1rem;">
|
||||||
|
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Event Log Removed from inline layout -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||||
|
<div class="private-column">
|
||||||
|
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
||||||
|
{#if state.player.role === "deep"}
|
||||||
|
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
||||||
|
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||||
|
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} on:change={() => toggleObjective(state.player.id, 'crew_1')}>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} on:change={() => toggleObjective(state.player.id, 'crew_2')}>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} on:change={() => toggleObjective(state.player.id, 'crew_3')}>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
|
||||||
|
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
||||||
|
{#each state.players.filter(p => p.role === 'pirat') as p}
|
||||||
|
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_1} on:change={() => toggleObjective(p.id, 'personal_1')}> <span>1. Gat</span></label>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_2} on:change={() => toggleObjective(p.id, 'personal_2')}> <span>2. Name</span></label>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" checked={p.completed_personal_3} on:change={() => toggleObjective(p.id, 'personal_3')}> <span>3. Die/Retire</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End Scene Button -->
|
||||||
|
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||||
|
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
||||||
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
|
End Scene & Proceed to Ranking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Player Hand Card -->
|
||||||
|
<div class="card glass-panel hand-card">
|
||||||
|
<h3>🃏 Your Hand</h3>
|
||||||
|
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}Drag a card onto an active obstacle to play it.{/if}</p>
|
||||||
|
|
||||||
|
<div class="hand-flex">
|
||||||
|
{#each hand as card}
|
||||||
|
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||||
|
draggable={state.player.role !== "deep"}
|
||||||
|
on:dragstart={(e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', card);
|
||||||
|
e.currentTarget.classList.add("dragging");
|
||||||
|
}}
|
||||||
|
on:dragend={(e) => {
|
||||||
|
e.currentTarget.classList.remove("dragging");
|
||||||
|
}}>
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-center">
|
||||||
|
{#if card === 'JOKER'}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-tech-overlay">
|
||||||
|
{#if card.slice(0, -1) === "J"}
|
||||||
|
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
|
||||||
|
{:else if card.slice(0, -1) === "Q"}
|
||||||
|
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
|
||||||
|
{:else if card.slice(0, -1) === "K"}
|
||||||
|
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Character Sheet Card -->
|
||||||
|
{#if state.player.role !== "deep"}
|
||||||
|
<div class="card glass-panel sheet-card">
|
||||||
|
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||||
|
|
||||||
|
<div class="character-sheet-details">
|
||||||
|
{#if state.player.is_dead}
|
||||||
|
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||||
|
<h4 style="color: var(--red-suit); margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">💀 You have Died / Retired!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if state.player.is_ghost}
|
||||||
|
<div class="sheet-group prompt-box" style="background: rgba(120, 150, 180, 0.1); border: 1px dashed #7890a8; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||||
|
<h4 style="color: #a0b0c0; margin-top: 0; font-family: var(--font-heading); font-size: 1.1rem; font-weight: 700;">👻 Playing as a Ghost</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if state.player.needs_name}
|
||||||
|
<div class="sheet-group prompt-box" style="background: rgba(212, 175, 55, 0.1); border: 1px dashed var(--gold); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
|
<h4 style="color: var(--gold); margin-top: 0;">🏴☠️ You Earned a Name!</h4>
|
||||||
|
<p class="info-text" style="margin-bottom: 0.5rem;">You completed your second objective and ranked up! What is your new Pirate Name?</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" bind:value={newNameInput} class="form-control" placeholder="Enter new name...">
|
||||||
|
<button class="btn btn-gold" on:click={submitNewName} disabled={!newNameInput.trim()}>Claim Name</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sheet-group">
|
||||||
|
<h4>Description:</h4>
|
||||||
|
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||||
|
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||||
|
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||||
|
<ul class="techniques-list-sheet">
|
||||||
|
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
|
||||||
|
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
|
||||||
|
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
|
||||||
|
<span>Steal a Ship</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
|
||||||
|
<span>Choose a Captain</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
|
||||||
|
<span>Commit Piracy</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-group margin-top">
|
||||||
|
<h4 style="color: var(--gold);">Personal Objectives</h4>
|
||||||
|
<div class="objectives-checklist">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
|
||||||
|
<span>1. Gat</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
|
||||||
|
<span>2. Name</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
|
||||||
|
<span>3. Die/Retire</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Event Log -->
|
||||||
|
<div class="floating-event-log {showEventLog ? 'open' : ''}">
|
||||||
|
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
||||||
|
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showEventLog}
|
||||||
|
<div class="log-content font-mono text-sm space-y-2 p-2">
|
||||||
|
{#each state.events as event}
|
||||||
|
<div class="p-2 border-l-2 border-gray-600 bg-black text-gray-400" style="border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 0.25rem;">
|
||||||
|
{event.message}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-2 text-gray-500">No events yet.</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.floating-event-log {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 350px;
|
||||||
|
background: rgba(10, 15, 25, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 60vh;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.toggle-log-btn {
|
||||||
|
background: var(--dark-bg, #1a1a2e);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.toggle-log-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.log-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.floating-event-log:not(.open) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
frontend/src/components/SceneSetupPhase.svelte
Normal file
131
frontend/src/components/SceneSetupPhase.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let settingRole = false;
|
||||||
|
let starting = false;
|
||||||
|
let error = '';
|
||||||
|
let allowedRoles = ['pirat', 'deep'];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/allowed-roles`, 'GET');
|
||||||
|
allowedRoles = res.allowed_roles;
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Failed to load allowed roles", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setRole(role) {
|
||||||
|
settingRole = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-role`, 'POST', { role });
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
settingRole = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScene() {
|
||||||
|
starting = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/scene/start`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
error = e.message;
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: allRolesAssigned = state.players.every(p => p.role !== null);
|
||||||
|
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||||
|
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||||
|
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scene-setup-view text-center">
|
||||||
|
<h2>Scene Setup (Scene #{state.game.current_scene_number})</h2>
|
||||||
|
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
||||||
|
|
||||||
|
<div class="setup-grid">
|
||||||
|
<!-- Role Selection Card -->
|
||||||
|
<div class="card glass-panel role-selection-card">
|
||||||
|
<h3>Select Your Role</h3>
|
||||||
|
|
||||||
|
<div class="role-buttons">
|
||||||
|
<button on:click={() => setRole('pirat')}
|
||||||
|
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||||
|
class="btn btn-large role-btn pirat-btn {state.player.role === 'pirat' ? 'active' : ''}">
|
||||||
|
🐀 Play Pi-Rat
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click={() => setRole('deep')}
|
||||||
|
disabled={settingRole || !allowedRoles.includes('deep')}
|
||||||
|
class="btn btn-large role-btn deep-btn {state.player.role === 'deep' ? 'active' : ''}">
|
||||||
|
🌊 Play the Deep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-restrictions mt-4">
|
||||||
|
{#if !allowedRoles.includes('pirat')}
|
||||||
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<p class="text-red-400 text-sm italic">You are the highest ranked player, so you must play The Deep for the first scene!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if !allowedRoles.includes('deep')}
|
||||||
|
{#if state.game.current_scene_number === 1}
|
||||||
|
<p class="text-red-400 text-sm italic">Only the highest ranked player(s) can play The Deep in the first scene!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Roster Card -->
|
||||||
|
<div class="card glass-panel roster-card">
|
||||||
|
<h3>Live Roster Selection</h3>
|
||||||
|
<div class="roster-list" id="scene-roster-list">
|
||||||
|
{#each state.players as p}
|
||||||
|
<div class="roster-item">
|
||||||
|
<span class="player-name">{p.name} <span class="text-sm text-gray-400">(Rank {p.rank})</span></span>
|
||||||
|
{#if p.role === 'pirat'}
|
||||||
|
<span class="role-badge pirat">Pi-Rat</span>
|
||||||
|
{:else if p.role === 'deep'}
|
||||||
|
<span class="role-badge deep">The Deep</span>
|
||||||
|
{:else}
|
||||||
|
<span class="role-badge unassigned">Choosing...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-box margin-top">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canStart}
|
||||||
|
<button on:click={startScene}
|
||||||
|
disabled={starting}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
{starting ? 'Starting Scene...' : 'Confirm Roles & Shuffle Deck'}
|
||||||
|
</button>
|
||||||
|
{:else if !allRolesAssigned}
|
||||||
|
<p class="text-gray-400 italic mt-4">Waiting for all players to choose a role...</p>
|
||||||
|
{:else if !deepPlayer}
|
||||||
|
<p class="text-red-400 italic mt-4">Someone must play as The Deep!</p>
|
||||||
|
{:else if !piratPlayer}
|
||||||
|
<p class="text-red-400 italic mt-4">Someone must play as a Pi-Rat!</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 italic mt-4">Waiting for The Deep or Creator to start the scene...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
437
frontend/src/components/UpkeepPhase.svelte
Normal file
437
frontend/src/components/UpkeepPhase.svelte
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
import { getSuggestion } from '../lib/suggestions';
|
||||||
|
|
||||||
|
export let state;
|
||||||
|
|
||||||
|
let voting = false;
|
||||||
|
let readying = false;
|
||||||
|
let confirming = false;
|
||||||
|
|
||||||
|
let selectedVoteId = '';
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
|
||||||
|
// Drag & Drop State
|
||||||
|
let keepCards = [];
|
||||||
|
let discardCards = [];
|
||||||
|
let dragNDropInitialized = false;
|
||||||
|
|
||||||
|
// Ghost & Re-rolling fate states
|
||||||
|
let isRolling = false;
|
||||||
|
let reRollDetails = {
|
||||||
|
avatar_look: '',
|
||||||
|
avatar_smell: '',
|
||||||
|
first_words: '',
|
||||||
|
good_at_math: ''
|
||||||
|
};
|
||||||
|
let rollingError = '';
|
||||||
|
|
||||||
|
async function becomeGhost() {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReRoll() {
|
||||||
|
if (!reRollDetails.avatar_look.trim() ||
|
||||||
|
!reRollDetails.avatar_smell.trim() ||
|
||||||
|
!reRollDetails.first_words.trim() ||
|
||||||
|
!reRollDetails.good_at_math.trim()) {
|
||||||
|
rollingError = 'Please fill out all character details.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rollingError = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/roll-new-character`, 'POST', reRollDetails);
|
||||||
|
isRolling = false;
|
||||||
|
// Clear inputs
|
||||||
|
reRollDetails = {
|
||||||
|
avatar_look: '',
|
||||||
|
avatar_smell: '',
|
||||||
|
first_words: '',
|
||||||
|
good_at_math: ''
|
||||||
|
};
|
||||||
|
} catch(e) {
|
||||||
|
rollingError = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (state && state.game.phase === 'deep_upkeep') {
|
||||||
|
if (!dragNDropInitialized) {
|
||||||
|
keepCards = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||||
|
discardCards = [];
|
||||||
|
dragNDropInitialized = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dragNDropInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedCardCode = null;
|
||||||
|
|
||||||
|
function handleDragStart(e, card) {
|
||||||
|
draggedCardCode = card;
|
||||||
|
e.dataTransfer.setData("text/plain", card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropToKeep(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||||
|
if (card && !keepCards.includes(card)) {
|
||||||
|
discardCards = discardCards.filter(c => c !== card);
|
||||||
|
keepCards = [...keepCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropToDiscard(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
||||||
|
if (card && !discardCards.includes(card)) {
|
||||||
|
keepCards = keepCards.filter(c => c !== card);
|
||||||
|
discardCards = [...discardCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToDiscard(card) {
|
||||||
|
keepCards = keepCards.filter(c => c !== card);
|
||||||
|
if (!discardCards.includes(card)) {
|
||||||
|
discardCards = [...discardCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToKeep(card) {
|
||||||
|
discardCards = discardCards.filter(c => c !== card);
|
||||||
|
if (!keepCards.includes(card)) {
|
||||||
|
keepCards = [...keepCards, card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: maxHandSize = state.player.rank + 1;
|
||||||
|
$: isOverLimit = keepCards.length > maxHandSize;
|
||||||
|
|
||||||
|
async function submitVote() {
|
||||||
|
if (!selectedVoteId) return;
|
||||||
|
voting = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
|
||||||
|
nominated_id: selectedVoteId
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
voting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setReady() {
|
||||||
|
readying = true;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
readying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRefresh() {
|
||||||
|
confirming = true;
|
||||||
|
try {
|
||||||
|
let discards = JSON.stringify(discardCards);
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/confirm-refresh`, 'POST', {
|
||||||
|
discard_cards: discards
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
confirming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="between-scenes-view text-center">
|
||||||
|
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
|
||||||
|
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
|
||||||
|
|
||||||
|
{#if state.player.is_dead && !state.player.is_ghost}
|
||||||
|
<!-- Death Fate Panel -->
|
||||||
|
<div class="card glass-panel death-fate-card" style="border: 2px solid var(--red-suit); background: rgba(255, 42, 95, 0.05); padding: 2.5rem; margin: 2rem auto; max-width: 650px; border-radius: 12px; box-shadow: 0 0 20px rgba(255,42,95,0.15); text-align: center;">
|
||||||
|
<h2 style="color: var(--red-suit); font-family: var(--font-heading); margin-bottom: 1rem; font-size: 2rem;">💀 Your Pi-Rat Has Died!</h2>
|
||||||
|
<p class="description" style="font-size: 1.15rem; color: var(--text-primary); margin-bottom: 2rem; line-height: 1.6;">
|
||||||
|
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if !isRolling}
|
||||||
|
<div style="display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap;">
|
||||||
|
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost} style="border: 1px solid var(--text-muted); font-size: 1.1rem; padding: 0.8rem 2rem;">
|
||||||
|
👻 Remain as a Ghost
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true} style="background: var(--gold); border: 1px solid var(--gold); color: black; font-size: 1.1rem; padding: 0.8rem 2rem;">
|
||||||
|
🐀 Roll a New Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
||||||
|
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh with a randomized starting Rank between 1 and 3.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<h3 style="color: var(--gold); font-family: var(--font-heading); margin-bottom: 1.5rem; border-bottom: 1px solid rgba(212,175,55,0.2); padding-bottom: 0.5rem;">Create Your New Recruit</h3>
|
||||||
|
|
||||||
|
{#if rollingError}
|
||||||
|
<div class="alert alert-danger" style="margin-bottom: 1.5rem;">{rollingError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat look like?</label>
|
||||||
|
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" placeholder="e.g. Scruffy snout, wearing a tattered vest" bind:value={reRollDetails.avatar_look} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_look = getSuggestion('look')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What does your Pi-Rat smell like? (Determines name)</label>
|
||||||
|
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" placeholder="e.g. Spiced rum, salty cheese, gunpowder" bind:value={reRollDetails.avatar_smell} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_smell = getSuggestion('smell')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">What were your first words after transforming?</label>
|
||||||
|
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" placeholder="e.g. Shiver my decimals!" bind:value={reRollDetails.first_words} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.first_words = getSuggestion('first_words')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
|
<label style="display: block; font-weight: bold; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-primary); font-family: var(--font-heading);">Is your Pi-Rat good at Math?</label>
|
||||||
|
<div class="input-row" style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" placeholder="e.g. Thinks Pi is a dessert" bind:value={reRollDetails.good_at_math} required class="input-field" style="flex: 1; padding: 0.6rem; border-radius: 4px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.3); color: white;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.good_at_math = getSuggestion('good_at_math')} style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Suggest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
|
||||||
|
<button class="btn btn-secondary" on:click={() => isRolling = false} style="flex: 1; padding: 0.75rem;">Cancel</button>
|
||||||
|
<button class="btn btn-primary" on:click={submitReRoll} style="flex: 2; padding: 0.75rem; background: var(--gold); border: 1px solid var(--gold); color: black;">Submit New Character</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="between-grid">
|
||||||
|
<!-- Ranks and Voting Card -->
|
||||||
|
<div class="card glass-panel voting-card">
|
||||||
|
<h3>1. Pirate Ranking (Voting)</h3>
|
||||||
|
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
||||||
|
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
||||||
|
</div>
|
||||||
|
{:else if state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||||
|
</div>
|
||||||
|
{:else if state.player.role === 'deep'}
|
||||||
|
<div class="voted-confirmation-box glass-panel">
|
||||||
|
<p class="info-text">The Deep does not vote.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||||
|
<div class="form-group inline-group">
|
||||||
|
<select class="select-field" bind:value={selectedVoteId} required>
|
||||||
|
<option value="">Nominate crewmate...</option>
|
||||||
|
{#each state.players as p}
|
||||||
|
{#if p.id !== state.player.id && p.role !== 'deep'}
|
||||||
|
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Voting Roster Status -->
|
||||||
|
{#if state.game.phase !== 'deep_upkeep'}
|
||||||
|
<div class="vote-status-table margin-top">
|
||||||
|
<h4>Nomination Progress:</h4>
|
||||||
|
<div class="player-chips list-chips" id="voting-roster">
|
||||||
|
{#each state.players as p}
|
||||||
|
{@const voted = state.votes.some(v => v.voter_player_id === p.id) || p.role === 'deep'}
|
||||||
|
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
|
||||||
|
<span class="name">{p.name}</span>
|
||||||
|
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resting Deep Player Card -->
|
||||||
|
<div class="card glass-panel deep-rest-card">
|
||||||
|
<h3>2. Resting Deep Upkeep</h3>
|
||||||
|
|
||||||
|
{#if state.player.role === "deep"}
|
||||||
|
<div class="deep-rest-panel glass-panel">
|
||||||
|
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
||||||
|
|
||||||
|
{#if state.game.phase === 'between_scenes'}
|
||||||
|
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
||||||
|
{:else if state.game.phase === 'deep_upkeep'}
|
||||||
|
<p class="info-text" style="margin-top: 1rem;">
|
||||||
|
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
||||||
|
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="upkeep-drag-container">
|
||||||
|
<!-- KEEP HAND ZONE -->
|
||||||
|
<div class="upkeep-box"
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDropToKeep}>
|
||||||
|
<h4 class="gold-text">Hand (Keep)</h4>
|
||||||
|
<div class="upkeep-flex">
|
||||||
|
{#each keepCards as card}
|
||||||
|
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||||
|
draggable="true"
|
||||||
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
|
on:click={() => moveToDiscard(card)}
|
||||||
|
style="cursor: pointer;"
|
||||||
|
title="Click or drag to discard">
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-center">
|
||||||
|
{#if card === 'JOKER'}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DISCARD PILE ZONE -->
|
||||||
|
<div class="upkeep-box"
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDropToDiscard}>
|
||||||
|
<h4 class="gold-text">Discard Pile</h4>
|
||||||
|
<div class="upkeep-flex">
|
||||||
|
{#each discardCards as card}
|
||||||
|
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||||
|
draggable="true"
|
||||||
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
|
on:click={() => moveToKeep(card)}
|
||||||
|
style="cursor: pointer;"
|
||||||
|
title="Click or drag to keep">
|
||||||
|
<div class="card-corner top-left">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-center">
|
||||||
|
{#if card === 'JOKER'}
|
||||||
|
🃏
|
||||||
|
{:else}
|
||||||
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-corner bottom-right">
|
||||||
|
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||||
|
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isOverLimit}
|
||||||
|
<div style="border: 1px solid var(--red-suit); border-radius: 4px; padding: 1rem; margin: 1rem 0; background: rgba(255, 42, 95, 0.1);">
|
||||||
|
<p style="color: var(--red-suit); font-weight: bold; margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
|
||||||
|
Confirm Hand Refresh
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if state.game.phase === 'between_scenes'}
|
||||||
|
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
||||||
|
{:else if state.game.phase === 'deep_upkeep'}
|
||||||
|
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
||||||
|
<h4>Crew Rank Ledger:</h4>
|
||||||
|
<ul class="ranks-ledger">
|
||||||
|
{#each state.players as p}
|
||||||
|
<li>
|
||||||
|
<strong>{p.name}:</strong> Rank {p.rank}
|
||||||
|
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge" style="background: rgba(120, 150, 180, 0.15); border: 1px solid #7890a8; color: #a0b0c0; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-left: 0.5rem; display: inline-block;">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Scene Ready Up -->
|
||||||
|
<div class="action-box margin-top">
|
||||||
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
|
{#if state.player.role === 'deep'}
|
||||||
|
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="waiting-box">
|
||||||
|
<p>Waiting for resting Deep player to refresh their hand...</p>
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if state.player.role === 'pirat' && !state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||||
|
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
||||||
|
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||||
|
{:else}
|
||||||
|
{#if state.player.is_ready}
|
||||||
|
<div class="waiting-box">
|
||||||
|
<p>Ready! Waiting for other players to ready up...</p>
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button on:click={setReady}
|
||||||
|
disabled={readying}
|
||||||
|
class="btn btn-primary btn-large glow-effect">
|
||||||
|
Ready for Next Scene
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
5
frontend/src/lib/Counter.svelte
Normal file
5
frontend/src/lib/Counter.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
let count = $state(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
||||||
23
frontend/src/lib/api.js
Normal file
23
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export async function apiRequest(endpoint, method = 'GET', data = null) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
options.body = new URLSearchParams(data).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api${endpoint}`, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (errBody.error) msg = errBody.error;
|
||||||
|
else if (errBody.detail) msg = errBody.detail;
|
||||||
|
} catch(e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
1423
frontend/src/lib/suggestions.js
Normal file
1423
frontend/src/lib/suggestions.js
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { mount } from 'svelte'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
89
frontend/src/pages/Admin.svelte
Normal file
89
frontend/src/pages/Admin.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
export let params = {};
|
||||||
|
let gameId = params.id;
|
||||||
|
let adminKey = '';
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Try to load admin_key from local storage
|
||||||
|
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
||||||
|
if (adminKey) {
|
||||||
|
loadAdminData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAdminData() {
|
||||||
|
if (!adminKey) return;
|
||||||
|
try {
|
||||||
|
data = await apiRequest(`/game/${gameId}/admin?key=${encodeURIComponent(adminKey)}`);
|
||||||
|
error = '';
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gold">Admin Panel</h1>
|
||||||
|
|
||||||
|
{#if !data}
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl mb-4">Enter Admin Key</h2>
|
||||||
|
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
||||||
|
<input type="text" bind:value={adminKey} class="form-control flex-grow" placeholder="Admin Key" required/>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger mt-4">{error}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-bold text-silver mb-4">Game: {data.game.crew_name}</h2>
|
||||||
|
<p><strong>Phase:</strong> {data.game.phase}</p>
|
||||||
|
<p><strong>Admin Key:</strong> <span class="bg-dark-900 px-2 py-1 font-mono text-sm">{data.game.admin_key}</span></p>
|
||||||
|
|
||||||
|
<h3 class="text-xl font-bold text-silver mt-8 mb-4">Players</h3>
|
||||||
|
<table class="w-full text-left bg-dark-900 rounded border border-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<th class="p-3">Name</th>
|
||||||
|
<th class="p-3">Role</th>
|
||||||
|
<th class="p-3">Status</th>
|
||||||
|
<th class="p-3">Rank</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.players as p}
|
||||||
|
<tr class="border-b border-gray-800">
|
||||||
|
<td class="p-3">{p.name}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{#if p.role === 'deep'} 🌊 Deep
|
||||||
|
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
||||||
|
{:else} None
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{#if p.is_ghost} 👻 Ghost
|
||||||
|
{:else if p.is_dead} 💀 Dead
|
||||||
|
{:else} Alive
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="p-3">{p.rank}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
77
frontend/src/pages/Dashboard.svelte
Normal file
77
frontend/src/pages/Dashboard.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||||
|
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||||
|
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||||
|
import ScenePhase from '../components/ScenePhase.svelte';
|
||||||
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
|
|
||||||
|
export let params = {};
|
||||||
|
let gameId = params.id;
|
||||||
|
let playerId = params.pid;
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
let error = '';
|
||||||
|
let intervalId;
|
||||||
|
|
||||||
|
async function fetchState() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/${gameId}/player/${playerId}/state`);
|
||||||
|
state = data;
|
||||||
|
error = '';
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchState();
|
||||||
|
// Poll every 2 seconds
|
||||||
|
intervalId = setInterval(fetchState, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" style="margin: 20px;">
|
||||||
|
Error connecting to game: {error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if state}
|
||||||
|
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
|
||||||
|
{#if state.game.phase === 'lobby'}
|
||||||
|
<LobbyPhase {state} />
|
||||||
|
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques'}
|
||||||
|
<CharacterCreationPhase {state} />
|
||||||
|
{:else if state.game.phase === 'scene_setup'}
|
||||||
|
<SceneSetupPhase {state} />
|
||||||
|
{:else if state.game.phase === 'scene'}
|
||||||
|
<ScenePhase {state} />
|
||||||
|
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||||
|
<UpkeepPhase {state} />
|
||||||
|
{:else}
|
||||||
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !error}
|
||||||
|
<div class="loading-container">
|
||||||
|
<p>Loading game state...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
frontend/src/pages/Home.svelte
Normal file
61
frontend/src/pages/Home.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script>
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
|
||||||
|
let crewName = '';
|
||||||
|
let error = '';
|
||||||
|
let creating = false;
|
||||||
|
|
||||||
|
async function createGame() {
|
||||||
|
if (!crewName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/game', 'POST', { crew_name: crewName });
|
||||||
|
if (data.admin_key) {
|
||||||
|
localStorage.setItem(`admin_key_${data.id}`, data.admin_key);
|
||||||
|
}
|
||||||
|
push(`/game/${data.id}/join?creator=true`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="welcome-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>RATS with GATS</h1>
|
||||||
|
<p class="subtitle">A Remote Play Companion App</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card creation-card">
|
||||||
|
<h2>Start a New Game</h2>
|
||||||
|
<form on:submit|preventDefault={createGame}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="crewName">Crew Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="crewName"
|
||||||
|
bind:value={crewName}
|
||||||
|
required
|
||||||
|
placeholder="e.g. The Salty Dogs"
|
||||||
|
class="form-control input-large"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={creating}>
|
||||||
|
{creating ? 'Creating...' : 'Create Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links mt-4">
|
||||||
|
<a href="/rules" class="text-muted">Read the Rules</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
58
frontend/src/pages/Join.svelte
Normal file
58
frontend/src/pages/Join.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script>
|
||||||
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { apiRequest } from '../lib/api';
|
||||||
|
export let params = {};
|
||||||
|
let gameId = params.id;
|
||||||
|
let playerName = '';
|
||||||
|
let error = '';
|
||||||
|
let joining = false;
|
||||||
|
|
||||||
|
// We can extract ?creator=true from $querystring if needed,
|
||||||
|
// though the backend determines creator based on if they are the first player.
|
||||||
|
// For UI purposes, we could show "You are the creator" if we want.
|
||||||
|
|
||||||
|
async function joinGame() {
|
||||||
|
if (!playerName.trim()) return;
|
||||||
|
joining = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||||
|
push(`/game/${gameId}/player/${data.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
joining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="welcome-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Join Crew</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card creation-card">
|
||||||
|
<h2>Enter your Pi-Rat name</h2>
|
||||||
|
<form on:submit|preventDefault={joinGame}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playerName">Your Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="playerName"
|
||||||
|
bind:value={playerName}
|
||||||
|
required
|
||||||
|
class="form-control input-large"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-block mt-4" disabled={joining}>
|
||||||
|
{joining ? 'Joining...' : 'Join Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
2
frontend/svelte.config.js
Normal file
2
frontend/svelte.config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -12,7 +12,6 @@ dependencies = [
|
|||||||
"fastapi",
|
"fastapi",
|
||||||
"sqlmodel",
|
"sqlmodel",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"jinja2",
|
|
||||||
"python-multipart",
|
"python-multipart",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ pirats = "pirats.main:main"
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
pirats = ["static/**/*", "templates/**/*.html", "templates/*.html"]
|
pirats = ["static/**/*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|||||||
@@ -63,11 +63,27 @@ def trigger_technique_swap(db: Session, game: Game):
|
|||||||
|
|
||||||
# Build a pool of all techniques with their creators
|
# Build a pool of all techniques with their creators
|
||||||
pool = []
|
pool = []
|
||||||
|
seen_texts = set()
|
||||||
|
has_duplicates = False
|
||||||
|
|
||||||
for p in players:
|
for p in players:
|
||||||
techs = json.loads(p.created_techniques)
|
techs = json.loads(p.created_techniques)
|
||||||
for t in techs:
|
for t in techs:
|
||||||
|
t_clean = t.strip().lower()
|
||||||
|
if t_clean in seen_texts:
|
||||||
|
has_duplicates = True
|
||||||
|
seen_texts.add(t_clean)
|
||||||
pool.append({"text": t, "creator_id": p.id})
|
pool.append({"text": t, "creator_id": p.id})
|
||||||
|
|
||||||
|
if has_duplicates:
|
||||||
|
# Force players to resubmit
|
||||||
|
for p in players:
|
||||||
|
p.created_techniques = "[]"
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
add_game_event(db, game.id, "Duplicate techniques submitted! The pool has been cleared. All players must try again with unique technique names.")
|
||||||
|
return
|
||||||
|
|
||||||
# Swap algorithm with simple retry backtracking
|
# Swap algorithm with simple retry backtracking
|
||||||
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
|
# We want to assign 3 techniques from the pool to each player, ensuring creator_id != player.id.
|
||||||
# For a small number of players, random shuffling with retries is highly efficient.
|
# For a small number of players, random shuffling with retries is highly efficient.
|
||||||
@@ -174,3 +190,88 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
add_game_event(db, game.id, "Phase changed: Scene Setup")
|
add_game_event(db, game.id, "Phase changed: Scene Setup")
|
||||||
|
|
||||||
|
def roll_new_character(
|
||||||
|
db: Session,
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
avatar_look: str,
|
||||||
|
avatar_smell: str,
|
||||||
|
first_words: str,
|
||||||
|
good_at_math: str
|
||||||
|
):
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not player or not game:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Return current hand cards to the deck
|
||||||
|
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size
|
||||||
|
hand = get_player_hand(player)
|
||||||
|
deck = get_game_deck(game)
|
||||||
|
deck.extend(hand)
|
||||||
|
random.shuffle(deck)
|
||||||
|
set_game_deck(game, deck)
|
||||||
|
set_player_hand(player, [])
|
||||||
|
|
||||||
|
# 2. Reset properties & randomize starting rank between 1 and 3
|
||||||
|
player.rank = random.randint(1, 3)
|
||||||
|
player.is_ready = False
|
||||||
|
player.completed_personal_1 = False
|
||||||
|
player.completed_personal_2 = False
|
||||||
|
player.completed_personal_3 = False
|
||||||
|
player.needs_name = False
|
||||||
|
player.needs_rank_3_bonus = False
|
||||||
|
player.is_dead = False
|
||||||
|
player.is_ghost = False
|
||||||
|
|
||||||
|
# Save player-provided details
|
||||||
|
player.avatar_look = avatar_look.strip()
|
||||||
|
player.avatar_smell = avatar_smell.strip()
|
||||||
|
player.first_words = first_words.strip()
|
||||||
|
player.good_at_math = good_at_math.strip()
|
||||||
|
|
||||||
|
# Scent/Name is "Recruit [Smell]"
|
||||||
|
clean_smell = avatar_smell.strip()
|
||||||
|
if clean_smell.lower().startswith("like "):
|
||||||
|
clean_smell = clean_smell[5:]
|
||||||
|
player.name = f"Recruit {clean_smell}"
|
||||||
|
|
||||||
|
# 3. Randomly assign 3 new secret techniques from cards.TECHNIQUE_SUGGESTIONS
|
||||||
|
from .cards import TECHNIQUE_SUGGESTIONS
|
||||||
|
chosen_techs = random.sample(TECHNIQUE_SUGGESTIONS, 3)
|
||||||
|
player.created_techniques = json.dumps(chosen_techs)
|
||||||
|
player.swapped_techniques = json.dumps(chosen_techs)
|
||||||
|
player.tech_jack = chosen_techs[0]
|
||||||
|
player.tech_queen = chosen_techs[1]
|
||||||
|
player.tech_king = chosen_techs[2]
|
||||||
|
|
||||||
|
# 4. Auto-delegate Like/Hate questions to other players
|
||||||
|
other_players = [p for p in game.players if p.id != player.id]
|
||||||
|
if other_players:
|
||||||
|
like_target = random.choice(other_players)
|
||||||
|
player.other_like_from_player_id = like_target.id
|
||||||
|
player.other_like = ""
|
||||||
|
|
||||||
|
if len(other_players) >= 2:
|
||||||
|
remaining = [op for op in other_players if op.id != like_target.id]
|
||||||
|
hate_target = random.choice(remaining)
|
||||||
|
else:
|
||||||
|
hate_target = random.choice(other_players)
|
||||||
|
player.other_hate_from_player_id = hate_target.id
|
||||||
|
player.other_hate = ""
|
||||||
|
else:
|
||||||
|
player.other_like_from_player_id = None
|
||||||
|
player.other_like = ""
|
||||||
|
player.other_hate_from_player_id = None
|
||||||
|
player.other_hate = ""
|
||||||
|
|
||||||
|
db.add(player)
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 5. Draw cards up to new hand size based on new Rank
|
||||||
|
max_size = calculate_max_hand_size(player, game.players)
|
||||||
|
draw_cards_for_player(db, game, player, max_size)
|
||||||
|
|
||||||
|
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!")
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
db.add(p)
|
db.add(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
if game.current_scene_number == 1:
|
||||||
|
max_rank = max((p.rank for p in players), default=1)
|
||||||
|
highest_ranked_players = [p for p in players if p.rank == max_rank]
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
if p.role == "deep" and p not in highest_ranked_players:
|
||||||
|
return False, f"Only the highest ranked player(s) (Rank {max_rank}) can play The Deep in the first scene."
|
||||||
|
|
||||||
|
if len(highest_ranked_players) == 1:
|
||||||
|
must_be_deep = highest_ranked_players[0]
|
||||||
|
if must_be_deep.role != "deep":
|
||||||
|
return False, f"{must_be_deep.name} is the highest ranked player and must play The Deep in the first scene."
|
||||||
|
|
||||||
# 1. Check previous Deep players
|
# 1. Check previous Deep players
|
||||||
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
||||||
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
||||||
@@ -150,6 +163,39 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
|
|
||||||
return True, "Scene started successfully!"
|
return True, "Scene started successfully!"
|
||||||
|
|
||||||
|
def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
player = get_player(db, player_id)
|
||||||
|
if not game or not player:
|
||||||
|
return []
|
||||||
|
|
||||||
|
players = game.players
|
||||||
|
allowed = ["pirat", "deep"]
|
||||||
|
|
||||||
|
if game.current_scene_number == 1:
|
||||||
|
max_rank = max((p.rank for p in players), default=1)
|
||||||
|
highest_ranked_players = [p for p in players if p.rank == max_rank]
|
||||||
|
|
||||||
|
if player not in highest_ranked_players:
|
||||||
|
if "deep" in allowed:
|
||||||
|
allowed.remove("deep")
|
||||||
|
elif len(highest_ranked_players) == 1:
|
||||||
|
if "pirat" in allowed:
|
||||||
|
allowed.remove("pirat")
|
||||||
|
elif game.current_scene_number > 1:
|
||||||
|
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
|
||||||
|
if len(players) >= 3 and player.previous_role == "deep":
|
||||||
|
if "deep" in allowed:
|
||||||
|
allowed.remove("deep")
|
||||||
|
|
||||||
|
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
||||||
|
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
||||||
|
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
|
||||||
|
if "pirat" in allowed:
|
||||||
|
allowed.remove("pirat")
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
|
||||||
# --- Scene Gameplay Operations ---
|
# --- Scene Gameplay Operations ---
|
||||||
|
|
||||||
def play_card_on_obstacle(
|
def play_card_on_obstacle(
|
||||||
@@ -182,6 +228,14 @@ def play_card_on_obstacle(
|
|||||||
played_parsed = cards.parse_card(card_code)
|
played_parsed = cards.parse_card(card_code)
|
||||||
orig_obs_parsed = cards.parse_card(obstacle.original_card)
|
orig_obs_parsed = cards.parse_card(obstacle.original_card)
|
||||||
|
|
||||||
|
# Check if already completed
|
||||||
|
played_list = json.loads(obstacle.played_cards)
|
||||||
|
current_successes = sum(1 for c in played_list if c.get("success") is True)
|
||||||
|
required_successes = sum(1 for p in game.players if p.role != "deep")
|
||||||
|
if current_successes >= required_successes:
|
||||||
|
return False, "This obstacle is already completed.", {}
|
||||||
|
|
||||||
|
|
||||||
# 1. Determine Success/Failure
|
# 1. Determine Success/Failure
|
||||||
success = False
|
success = False
|
||||||
details = ""
|
details = ""
|
||||||
@@ -416,3 +470,10 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return True, "Rolled back the last played card."
|
return True, "Rolled back the last played card."
|
||||||
|
|
||||||
|
def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
||||||
|
obstacle = db.get(Obstacle, obstacle_id)
|
||||||
|
if obstacle:
|
||||||
|
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.")
|
||||||
|
db.delete(obstacle)
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -1,49 +1,37 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
|
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status, APIRouter
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from .database import create_db_and_tables, get_session
|
from .database import create_db_and_tables, get_session
|
||||||
from .templates import templates, BASE_DIR
|
|
||||||
from .models import GameEvent
|
from .models import GameEvent
|
||||||
|
|
||||||
app = FastAPI(title="Rats with Gats Remote Play")
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
# Mount static files
|
app = FastAPI(title="Rats with Gats Remote Play API")
|
||||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
api = APIRouter()
|
||||||
|
|
||||||
# Register startup event to create tables
|
# Register startup event to create tables
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
|
|
||||||
# --- HTTP Core Route Handlers ---
|
# --- API Core Route Handlers ---
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@api.post("/game")
|
||||||
def home(request: Request):
|
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
|
||||||
|
|
||||||
@app.post("/game/create")
|
|
||||||
def create_game_route(
|
def create_game_route(
|
||||||
crew_name: str = Form(...),
|
crew_name: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
game = crud.create_game(db, crew_name=crew_name.strip())
|
game = crud.create_game(db, crew_name=crew_name.strip())
|
||||||
# Redirect creator to the join page with creator flag
|
return {"id": game.id}
|
||||||
return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/join", response_class=HTMLResponse)
|
@api.post("/game/{game_id}/join")
|
||||||
def join_game_form(request: Request, game_id: str, creator: bool = False, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
return templates.TemplateResponse("join_form.html", {"request": request, "game": game, "creator": creator})
|
|
||||||
|
|
||||||
@app.post("/game/{game_id}/join")
|
|
||||||
def join_game_submit(
|
def join_game_submit(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
@@ -56,80 +44,46 @@ def join_game_submit(
|
|||||||
# First player is automatically the creator
|
# First player is automatically the creator
|
||||||
is_creator = len(game.players) == 0
|
is_creator = len(game.players) == 0
|
||||||
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
|
player = crud.add_player(db, game_id, name.strip(), is_creator=is_creator)
|
||||||
|
return {"id": player.id}
|
||||||
|
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player.id}", status_code=status.HTTP_303_SEE_OTHER)
|
@api.get("/game/{game_id}/player/{player_id}/state")
|
||||||
|
def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_session)):
|
||||||
@app.get("/game/{game_id}/player/{player_id}", response_class=HTMLResponse)
|
|
||||||
def player_dashboard(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not game or not player:
|
if not game or not player:
|
||||||
raise HTTPException(status_code=404, detail="Game or Player not found")
|
raise HTTPException(status_code=404, detail="Game or Player not found")
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player
|
|
||||||
})
|
|
||||||
|
|
||||||
# --- HTMX Core Polling Route ---
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/phase-check")
|
|
||||||
def phase_check_route(game_id: str, player_id: str, current: str, rank: Optional[int] = None, needs_name: Optional[bool] = None, hand_len: Optional[int] = None, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
|
||||||
|
|
||||||
state_changed = False
|
|
||||||
if game.phase != current:
|
|
||||||
state_changed = True
|
|
||||||
elif rank is not None and player.rank != rank:
|
|
||||||
state_changed = True
|
|
||||||
elif needs_name is not None and player.needs_name != needs_name:
|
|
||||||
state_changed = True
|
|
||||||
elif hand_len is not None:
|
|
||||||
import json
|
|
||||||
current_hand = json.loads(player.hand_cards)
|
|
||||||
if len(current_hand) != hand_len:
|
|
||||||
state_changed = True
|
|
||||||
|
|
||||||
if state_changed:
|
|
||||||
# Phase or state changed! Trigger page reload on client via HTMX header
|
|
||||||
return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"})
|
|
||||||
return HTMLResponse(status_code=204) # No content, no reload
|
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/events", response_class=HTMLResponse)
|
|
||||||
def get_events_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all()
|
events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all()
|
||||||
|
|
||||||
return templates.TemplateResponse("event_log_snippet.html", {
|
return {
|
||||||
"request": request,
|
"game": game.model_dump(),
|
||||||
"events": events
|
"player": player.model_dump(),
|
||||||
})
|
"players": [p.model_dump() for p in game.players],
|
||||||
|
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||||
|
"votes": [v.model_dump() for v in game.votes],
|
||||||
|
"events": [e.model_dump() for e in events],
|
||||||
|
}
|
||||||
|
|
||||||
# --- Register Modular Phase Routers ---
|
# --- Register Modular Phase Routers ---
|
||||||
|
|
||||||
from .phase_view import router as phase_view_router
|
|
||||||
from .routes_lobby import router as lobby_router
|
from .routes_lobby import router as lobby_router
|
||||||
from .routes_character import router as character_router
|
from .routes_character import router as character_router
|
||||||
from .routes_scene import router as scene_router
|
from .routes_scene import router as scene_router
|
||||||
from .routes_upkeep import router as upkeep_router
|
from .routes_upkeep import router as upkeep_router
|
||||||
from .routes_admin import router as admin_router
|
from .routes_admin import router as admin_router
|
||||||
|
|
||||||
app.include_router(phase_view_router)
|
api.include_router(lobby_router)
|
||||||
app.include_router(lobby_router)
|
api.include_router(character_router)
|
||||||
app.include_router(character_router)
|
api.include_router(scene_router)
|
||||||
app.include_router(scene_router)
|
api.include_router(upkeep_router)
|
||||||
app.include_router(upkeep_router)
|
api.include_router(admin_router)
|
||||||
app.include_router(admin_router)
|
|
||||||
|
# Mount API at /api
|
||||||
|
app.include_router(api, prefix="/api")
|
||||||
|
|
||||||
|
# Mount SPA
|
||||||
|
static_dir = BASE_DIR / "static"
|
||||||
|
if os.path.exists(static_dir):
|
||||||
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class Game(SQLModel, table=True):
|
|||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
crew_name: Optional[str] = Field(default=None)
|
crew_name: Optional[str] = Field(default=None)
|
||||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes"
|
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep"
|
||||||
current_scene_number: int = Field(default=1)
|
current_scene_number: int = Field(default=1)
|
||||||
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
|
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
|
||||||
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
|
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import Request, Depends, APIRouter
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from .database import get_session
|
|
||||||
from .models import Game, Player, Obstacle, Vote
|
|
||||||
from . import crud
|
|
||||||
from . import cards
|
|
||||||
from .templates import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse)
|
|
||||||
def get_game_phase_view(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
edit: bool = False,
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
return HTMLResponse("<p class='alert alert-danger'>Session disconnected or expired.</p>")
|
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
|
|
||||||
# Context helper functions to run inside Jinja templates
|
|
||||||
def get_player_name_by_id(*args) -> str:
|
|
||||||
pid = args[1] if len(args) == 2 else (args[0] if len(args) == 1 else None)
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
|
|
||||||
return db.get(Obstacle, obs_id)
|
|
||||||
|
|
||||||
# Core variables
|
|
||||||
hand = crud.get_player_hand(player)
|
|
||||||
max_hand_size = crud.calculate_max_hand_size(player, game.players)
|
|
||||||
|
|
||||||
# Compute inbox tasks for current player
|
|
||||||
inbox_tasks = []
|
|
||||||
for p in game.players:
|
|
||||||
if p.id != player.id:
|
|
||||||
if p.other_like_from_player_id == player.id and not p.other_like:
|
|
||||||
inbox_tasks.append({"player": p, "type": "like"})
|
|
||||||
if p.other_hate_from_player_id == player.id and not p.other_hate:
|
|
||||||
inbox_tasks.append({"player": p, "type": "hate"})
|
|
||||||
|
|
||||||
# Freshly fetch votes to avoid cache stale
|
|
||||||
from sqlmodel import select
|
|
||||||
votes = db.exec(select(Vote).where(Vote.game_id == game_id)).all()
|
|
||||||
|
|
||||||
# Calculate eligible deep players
|
|
||||||
eligible_deeps = [p for p in game.players if p.previous_role != "deep"]
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"hand": hand,
|
|
||||||
"max_hand_size": max_hand_size,
|
|
||||||
"base_url": base_url,
|
|
||||||
"db": db,
|
|
||||||
"get_player_name": get_player_name_by_id,
|
|
||||||
"get_obstacle_by_id": get_obstacle_by_id,
|
|
||||||
"parse_card": cards.parse_card,
|
|
||||||
"is_captain": crud.is_player_captain,
|
|
||||||
"json_loads": json.loads,
|
|
||||||
"deck_count": len(crud.get_game_deck(game)),
|
|
||||||
"inbox_tasks": inbox_tasks,
|
|
||||||
"votes": votes,
|
|
||||||
"edit_mode": edit,
|
|
||||||
"eligible_deeps": eligible_deeps
|
|
||||||
}
|
|
||||||
|
|
||||||
# Select template based on current game phase
|
|
||||||
if game.phase == "lobby":
|
|
||||||
return templates.TemplateResponse("lobby_partial.html", context)
|
|
||||||
elif game.phase in ["character_creation", "swap_techniques"]:
|
|
||||||
return templates.TemplateResponse("character_creation_partial.html", context)
|
|
||||||
elif game.phase == "scene_setup":
|
|
||||||
return templates.TemplateResponse("scene_setup_partial.html", context)
|
|
||||||
elif game.phase == "scene":
|
|
||||||
return templates.TemplateResponse("scene_partial.html", context)
|
|
||||||
elif game.phase == "between_scenes":
|
|
||||||
return templates.TemplateResponse("between_scenes_partial.html", context)
|
|
||||||
elif game.phase == "deep_upkeep":
|
|
||||||
return templates.TemplateResponse("deep_upkeep_partial.html", context)
|
|
||||||
|
|
||||||
return HTMLResponse("<p>Unknown game state.</p>")
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .templates import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Creator Admin Panel
|
# Creator Admin Panel
|
||||||
@router.get("/game/{game_id}/admin", response_class=HTMLResponse)
|
@router.get("/game/{game_id}/admin")
|
||||||
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
def creator_admin_panel(request: Request, game_id: str, key: str, db: Session = Depends(get_session)):
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
@@ -17,8 +16,26 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
return templates.TemplateResponse("admin.html", {
|
|
||||||
"request": request,
|
players_data = []
|
||||||
"game": game,
|
for p in game.players:
|
||||||
"base_url": base_url
|
players_data.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"rank": p.rank,
|
||||||
|
"is_dead": p.is_dead,
|
||||||
|
"is_ghost": p.is_ghost,
|
||||||
|
"role": p.role,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"game": {
|
||||||
|
"id": game.id,
|
||||||
|
"crew_name": game.crew_name,
|
||||||
|
"phase": game.phase,
|
||||||
|
"admin_key": game.admin_key
|
||||||
|
},
|
||||||
|
"players": players_data,
|
||||||
|
"base_url": base_url
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import json
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException, status
|
from fastapi import APIRouter, Form, Depends, HTTPException, status
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .models import Player
|
|
||||||
from .templates import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -32,17 +29,7 @@ def player_save_basic_details(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Edit basic character details (reset values to trigger form view)
|
|
||||||
@router.post("/game/{game_id}/player/{player_id}/edit-basic")
|
|
||||||
def player_edit_basic_details(
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
# Trigger HTMX reload with edit mode active
|
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view?edit=true", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
||||||
@router.post("/game/{game_id}/player/{player_id}/delegate/auto")
|
@router.post("/game/{game_id}/player/{player_id}/delegate/auto")
|
||||||
@@ -79,39 +66,11 @@ def player_auto_delegate(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Get Crew Creation Status (polling endpoint)
|
|
||||||
@router.get("/game/{game_id}/player/{player_id}/crew-status", response_class=HTMLResponse)
|
|
||||||
def player_crew_status(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("crew_status_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"json_loads": json.loads,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Delegate question 5/6 to another player
|
# Delegate question 5/6 to another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/delegate/{question_type}", response_class=HTMLResponse)
|
@router.post("/game/{game_id}/player/{player_id}/delegate/{question_type}")
|
||||||
def player_delegate_question(
|
def player_delegate_question(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
question_type: str, # "like" or "hate"
|
question_type: str, # "like" or "hate"
|
||||||
@@ -119,29 +78,11 @@ def player_delegate_question(
|
|||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.delegate_question(db, player_id, question_type, target_player_id)
|
crud.delegate_question(db, player_id, question_type, target_player_id)
|
||||||
game = crud.get_game(db, game_id)
|
return {"status": "ok"}
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("delegation_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"question_type": question_type,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Revoke a delegated question task
|
# Revoke a delegated question task
|
||||||
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}", response_class=HTMLResponse)
|
@router.post("/game/{game_id}/player/{player_id}/revoke-delegate/{question_type}")
|
||||||
def player_revoke_delegation(
|
def player_revoke_delegation(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
question_type: str, # "like" or "hate"
|
question_type: str, # "like" or "hate"
|
||||||
@@ -159,51 +100,7 @@ def player_revoke_delegation(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
game = crud.get_game(db, game_id)
|
return {"status": "ok"}
|
||||||
if not game:
|
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("delegation_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"question_type": question_type,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fetch the delegation card status (for background polling when waiting)
|
|
||||||
@router.get("/game/{game_id}/player/{player_id}/delegation/{question_type}", response_class=HTMLResponse)
|
|
||||||
def player_get_delegation_status(
|
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
|
||||||
player_id: str,
|
|
||||||
question_type: str, # "like" or "hate"
|
|
||||||
db: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
def get_player_name_by_id(pid: Optional[str]) -> str:
|
|
||||||
if not pid:
|
|
||||||
return "None"
|
|
||||||
p = db.get(Player, pid)
|
|
||||||
return p.name if p else "Unknown"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("delegation_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"question_type": question_type,
|
|
||||||
"get_player_name": get_player_name_by_id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Answer a delegated question for another player
|
# Answer a delegated question for another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
@router.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||||
@@ -216,12 +113,11 @@ def player_submit_delegated_answer(
|
|||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
||||||
return HTMLResponse("")
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Submit 3 Secret Pirate Techniques
|
# Submit 3 Secret Pirate Techniques
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-techniques")
|
@router.post("/game/{game_id}/player/{player_id}/submit-techniques")
|
||||||
def player_submit_techniques(
|
def player_submit_techniques(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
tech1: str = Form(...),
|
tech1: str = Form(...),
|
||||||
@@ -230,23 +126,11 @@ def player_submit_techniques(
|
|||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
|
crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
|
||||||
|
return {"status": "ok"}
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse("techniques_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"json_loads": json.loads
|
|
||||||
})
|
|
||||||
|
|
||||||
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
||||||
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
@router.post("/game/{game_id}/player/{player_id}/assign-face-techniques")
|
||||||
def player_assign_face_techniques(
|
def player_assign_face_techniques(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
jack: str = Form(...),
|
jack: str = Form(...),
|
||||||
@@ -255,19 +139,29 @@ def player_assign_face_techniques(
|
|||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
if len({jack, queen, king}) < 3:
|
if len({jack, queen, king}) < 3:
|
||||||
# Prevent assigning the same technique to multiple face cards
|
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
|
||||||
return HTMLResponse("<p class='alert alert-danger'>Error: Each card must be assigned a unique technique.</p>", status_code=400)
|
|
||||||
|
|
||||||
crud.assign_face_card_techniques(db, player_id, jack, queen, king)
|
crud.assign_face_card_techniques(db, player_id, jack, queen, king)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
game = crud.get_game(db, game_id)
|
# Roll a new character after death
|
||||||
player = crud.get_player(db, player_id)
|
@router.post("/game/{game_id}/player/{player_id}/roll-new-character")
|
||||||
if not game or not player:
|
def roll_new_character_route(
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
return templates.TemplateResponse("techniques_snippet.html", {
|
avatar_look: str = Form(...),
|
||||||
"request": request,
|
avatar_smell: str = Form(...),
|
||||||
"game": game,
|
first_words: str = Form(...),
|
||||||
"player": player,
|
good_at_math: str = Form(...),
|
||||||
"json_loads": json.loads
|
db: Session = Depends(get_session)
|
||||||
})
|
):
|
||||||
|
crud.roll_new_character(
|
||||||
|
db,
|
||||||
|
game_id,
|
||||||
|
player_id,
|
||||||
|
avatar_look=avatar_look,
|
||||||
|
avatar_smell=avatar_smell,
|
||||||
|
first_words=first_words,
|
||||||
|
good_at_math=good_at_math
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .templates import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,15 +15,5 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
crud.add_game_event(db, game.id, "Phase changed: Character Creation")
|
crud.add_game_event(db, game.id, "Phase changed: Character Creation")
|
||||||
return HTMLResponse(status_code=204) # Return No Content, polling redirects
|
return {"status": "ok"}
|
||||||
|
|
||||||
@router.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)
|
|
||||||
def lobby_players_snippet(request: Request, game_id: str, player_id: Optional[str] = None, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("lobby_players_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player_id": player_id
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,65 +1,39 @@
|
|||||||
import json
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException
|
from fastapi import APIRouter, Form, Depends, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from . import cards
|
|
||||||
from .templates import templates
|
|
||||||
from .phase_view import get_game_phase_view
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Set Role during Scene Setup
|
# Set Role during Scene Setup
|
||||||
@router.post("/game/{game_id}/player/{player_id}/set-role", response_class=HTMLResponse)
|
@router.post("/game/{game_id}/player/{player_id}/set-role")
|
||||||
def player_set_role(
|
def player_set_role(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
role: str,
|
role: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.update_scene_role(db, player_id, role)
|
crud.update_scene_role(db, player_id, role)
|
||||||
return get_game_phase_view(request, game_id, player_id, db=db)
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@router.get("/game/{game_id}/player/{player_id}/allowed-roles")
|
||||||
|
def allowed_roles_route(
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
allowed = crud.get_allowed_roles(db, game_id, player_id)
|
||||||
|
return {"allowed_roles": allowed}
|
||||||
|
|
||||||
# Start Scene (Verify Setup & Shuffle & Draw Obstacles)
|
# Start Scene (Verify Setup & Shuffle & Draw Obstacles)
|
||||||
@router.post("/game/{game_id}/scene/start")
|
@router.post("/game/{game_id}/scene/start")
|
||||||
def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_session)):
|
def start_scene_route(game_id: str, db: Session = Depends(get_session)):
|
||||||
success, msg = crud.confirm_scene_setup(db, game_id)
|
success, msg = crud.confirm_scene_setup(db, game_id)
|
||||||
if not success:
|
if not success:
|
||||||
return HTMLResponse(f"<div class='alert alert-danger'>{msg}</div>", status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Get Scene Roster Snippet
|
|
||||||
@router.get("/game/{game_id}/scene/roster", response_class=HTMLResponse)
|
|
||||||
def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("scene_roster_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"is_captain": crud.is_player_captain
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get Scene Obstacles Snippet
|
|
||||||
@router.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse)
|
|
||||||
def scene_obstacles_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
player = crud.get_player(db, player_id)
|
|
||||||
if not game or not player:
|
|
||||||
return HTMLResponse("")
|
|
||||||
hand = crud.get_player_hand(player)
|
|
||||||
return templates.TemplateResponse("scene_obstacles_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game,
|
|
||||||
"player": player,
|
|
||||||
"hand": hand,
|
|
||||||
"parse_card": cards.parse_card,
|
|
||||||
"json_loads": json.loads,
|
|
||||||
"is_captain": crud.is_player_captain
|
|
||||||
})
|
|
||||||
|
|
||||||
# Pi-Rat plays card against an obstacle
|
# Pi-Rat plays card against an obstacle
|
||||||
@router.post("/game/{game_id}/player/{player_id}/play-card")
|
@router.post("/game/{game_id}/player/{player_id}/play-card")
|
||||||
@@ -72,18 +46,13 @@ def play_card_route(
|
|||||||
):
|
):
|
||||||
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
|
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
|
||||||
if not ok:
|
if not ok:
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {
|
||||||
color_prefix = "🟩" if res["success"] else "🟥"
|
"status": "ok",
|
||||||
drew_text = f" (Drew card: {res['drew_card']})" if res["drew_card"] else ""
|
"details": res["details"],
|
||||||
drew_attr = "true" if res["drew_card"] else "false"
|
"drew_card": res["drew_card"],
|
||||||
html = (
|
"success": res["success"]
|
||||||
f"<div class='play-notification' data-drew='{drew_attr}' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
|
}
|
||||||
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
|
|
||||||
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"})
|
|
||||||
|
|
||||||
# Pi-Rat plays a Joker to replace an obstacle
|
# Pi-Rat plays a Joker to replace an obstacle
|
||||||
@router.post("/game/{game_id}/player/{player_id}/play-joker")
|
@router.post("/game/{game_id}/player/{player_id}/play-joker")
|
||||||
@@ -96,22 +65,15 @@ def play_joker_route(
|
|||||||
):
|
):
|
||||||
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
|
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
|
||||||
if not ok:
|
if not ok:
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
|
return {"status": "ok", "message": "Played Joker: Discarded obstacle and drew a new replacement!"}
|
||||||
html = (
|
|
||||||
f"<div class='play-notification' data-drew='false' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
|
|
||||||
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
|
|
||||||
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
return HTMLResponse(html, headers={"HX-Trigger": "hand-changed"})
|
|
||||||
|
|
||||||
# Toggle Objective Completion Checklist
|
# Toggle Objective Completion Checklist
|
||||||
@router.post("/game/{game_id}/player/{player_id}/objective/toggle")
|
@router.post("/game/{game_id}/player/{player_id}/objective/toggle")
|
||||||
def toggle_objective_route(
|
def toggle_objective_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
type: str, # "personal_1", "personal_2", "personal_3", or "crew_1", etc.
|
type: str = Form(...), # "personal_1", "personal_2", "personal_3", or "crew_1", etc.
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
if type.startswith("crew_"):
|
if type.startswith("crew_"):
|
||||||
@@ -131,7 +93,7 @@ def toggle_objective_route(
|
|||||||
elif type == "personal_3": current_status = player.completed_personal_3
|
elif type == "personal_3": current_status = player.completed_personal_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
|
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep rollbacks a card play
|
# Deep rollbacks a card play
|
||||||
@router.post("/game/{game_id}/scene/rollback")
|
@router.post("/game/{game_id}/scene/rollback")
|
||||||
@@ -142,8 +104,8 @@ def rollback_card_play_route(
|
|||||||
):
|
):
|
||||||
ok, msg = crud.rollback_card_play(db, obstacle_id)
|
ok, msg = crud.rollback_card_play(db, obstacle_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return HTMLResponse(status_code=204, headers={"HX-Trigger": "hand-changed"})
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat sets a new name
|
# Pi-Rat sets a new name
|
||||||
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
@router.post("/game/{game_id}/player/{player_id}/set-name")
|
||||||
@@ -159,7 +121,7 @@ def set_name_route(
|
|||||||
player.needs_name = False
|
player.needs_name = False
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat bonus rank up for another player
|
# Pi-Rat bonus rank up for another player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||||
@@ -178,7 +140,7 @@ def bonus_rank_up_route(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
crud.evaluate_hand_sizes(db, game_id)
|
crud.evaluate_hand_sizes(db, game_id)
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat becomes a ghost
|
# Pi-Rat becomes a ghost
|
||||||
@router.post("/game/{game_id}/player/{player_id}/become-ghost")
|
@router.post("/game/{game_id}/player/{player_id}/become-ghost")
|
||||||
@@ -190,12 +152,23 @@ def become_ghost_route(
|
|||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if player and player.is_dead:
|
if player and player.is_dead:
|
||||||
player.is_ghost = True
|
player.is_ghost = True
|
||||||
|
player.is_dead = False
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Deep ends the scene
|
# Deep ends the scene
|
||||||
@router.post("/game/{game_id}/scene/end")
|
@router.post("/game/{game_id}/scene/end")
|
||||||
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
def end_scene_route(game_id: str, db: Session = Depends(get_session)):
|
||||||
crud.end_scene_and_transition(db, game_id)
|
crud.end_scene_and_transition(db, game_id)
|
||||||
return HTMLResponse(status_code=204)
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Deep clears a completed obstacle
|
||||||
|
@router.post("/game/{game_id}/scene/obstacle/{obstacle_id}/clear")
|
||||||
|
def clear_obstacle_route(
|
||||||
|
game_id: str,
|
||||||
|
obstacle_id: str,
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
crud.clear_completed_obstacle(db, game_id, obstacle_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException, status
|
from fastapi import APIRouter, Form, Depends, HTTPException, status
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from . import crud
|
from . import crud
|
||||||
from .templates import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Get Between Scenes Voting Roster Snippet
|
|
||||||
@router.get("/game/{game_id}/between/roster", response_class=HTMLResponse)
|
|
||||||
def between_scenes_roster_snippet(request: Request, game_id: str, db: Session = Depends(get_session)):
|
|
||||||
game = crud.get_game(db, game_id)
|
|
||||||
if not game:
|
|
||||||
return HTMLResponse("")
|
|
||||||
return templates.TemplateResponse("voting_roster_snippet.html", {
|
|
||||||
"request": request,
|
|
||||||
"game": game
|
|
||||||
})
|
|
||||||
|
|
||||||
# Cast vote to Rank Up a player
|
# Cast vote to Rank Up a player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/submit-vote")
|
@router.post("/game/{game_id}/player/{player_id}/submit-vote")
|
||||||
def submit_vote_route(
|
def submit_vote_route(
|
||||||
@@ -28,7 +16,7 @@ def submit_vote_route(
|
|||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
|
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Player Ready for Next Scene
|
# Player Ready for Next Scene
|
||||||
@router.post("/game/{game_id}/player/{player_id}/ready-next")
|
@router.post("/game/{game_id}/player/{player_id}/ready-next")
|
||||||
@@ -46,7 +34,7 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
|
|||||||
if game and all(p.is_ready for p in game.players):
|
if game and all(p.is_ready for p in game.players):
|
||||||
crud.transition_to_deep_upkeep(db, game_id)
|
crud.transition_to_deep_upkeep(db, game_id)
|
||||||
|
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Confirm Hand Refresh for Deep player
|
# Confirm Hand Refresh for Deep player
|
||||||
@router.post("/game/{game_id}/player/{player_id}/confirm-refresh")
|
@router.post("/game/{game_id}/player/{player_id}/confirm-refresh")
|
||||||
@@ -61,4 +49,4 @@ def confirm_deep_refresh_route(
|
|||||||
except Exception:
|
except Exception:
|
||||||
discards = []
|
discards = []
|
||||||
crud.confirm_deep_refresh(db, player_id, discards)
|
crud.confirm_deep_refresh(db, player_id, discards)
|
||||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
# Get the directory of this module
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
# Templates setup
|
|
||||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Creator Admin Panel - {% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="admin-panel-container glass-panel">
|
|
||||||
<div class="welcome-hero text-center">
|
|
||||||
<h2>Game Creator Admin Panel</h2>
|
|
||||||
<p class="subtitle">Use this dashboard to copy magic sheet links for players who misplace theirs.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-card-container">
|
|
||||||
<div class="card glass-panel">
|
|
||||||
<h3>Player Magic Links</h3>
|
|
||||||
<div class="admin-players-list">
|
|
||||||
{% for p in game.players %}
|
|
||||||
<div class="admin-player-row glass-panel">
|
|
||||||
<div class="player-info-basic">
|
|
||||||
<strong>{{ p.name }}</strong>
|
|
||||||
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
|
|
||||||
<span class="footnote-desc">(Rank {{ p.rank }}, Role: {{ p.role or 'None' }})</span>
|
|
||||||
</div>
|
|
||||||
<div class="link-copy-action">
|
|
||||||
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/player/{{ p.id }}" class="copy-input" id="player-link-{{ p.id }}">
|
|
||||||
<button onclick="navigator.clipboard.writeText(document.getElementById('player-link-{{ p.id }}').value); alert('Player magic link copied!');" class="btn btn-secondary btn-small">Copy Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text text-center">No players have joined yet.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card glass-panel text-center margin-top">
|
|
||||||
<h3>Lobby Details</h3>
|
|
||||||
<p><strong>Game Phase:</strong> {{ game.phase.replace('_', ' ').title() }}</p>
|
|
||||||
<p><strong>Scene Number:</strong> {{ game.current_scene_number }}</p>
|
|
||||||
<p><strong>Join URL (Share this with players):</strong></p>
|
|
||||||
<div class="copy-container center-block">
|
|
||||||
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/join" class="copy-input" id="join-link-copy">
|
|
||||||
<button onclick="navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!');" class="btn btn-primary btn-small">Copy Join Link</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="back-link margin-top">
|
|
||||||
<!-- Redirect back to the creator's personal magic dashboard link -->
|
|
||||||
{% set creator = None %}
|
|
||||||
{% for p in game.players if p.is_creator %}
|
|
||||||
{% set creator = p %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if creator %}
|
|
||||||
<a href="/game/{{ game.id }}/player/{{ creator.id }}" class="btn btn-secondary btn-full">Go to Creator Dashboard</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="/" class="btn btn-secondary btn-full">Go to Main Menu</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}Rats with Gats{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Google Fonts: serif header and modern sans-serif body -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Outfit:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Custom Weathered Math-Pirate Stylesheet -->
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
|
|
||||||
<!-- HTMX for reactive, AJAX-driven, multi-player dynamics -->
|
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
|
||||||
|
|
||||||
<!-- Suggestions pool for character creation -->
|
|
||||||
<script src="/static/js/suggestions.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="logo-container">
|
|
||||||
<span class="math-magic">π</span>
|
|
||||||
<h1>Rats with Gats</h1>
|
|
||||||
</div>
|
|
||||||
{% block header_extra %}{% endblock %}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="app-main">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="app-footer">
|
|
||||||
<p>Rats with Gats is a Story Game of mathematical piracy by Nick Smith and J. Richmond. Web-app built with FastAPI & HTMX.</p>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<div class="between-scenes-view text-center">
|
|
||||||
<h2>Between Scenes (Scene #{{ game.current_scene_number }} Concluded)</h2>
|
|
||||||
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
|
|
||||||
|
|
||||||
<div class="between-grid">
|
|
||||||
<!-- Ranks and Voting Card -->
|
|
||||||
<div class="card glass-panel voting-card">
|
|
||||||
<h3>1. Pirate Ranking (Voting)</h3>
|
|
||||||
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
|
||||||
|
|
||||||
{% set ns_vote = namespace(has_voted=False) %}
|
|
||||||
{% for v in votes %}
|
|
||||||
{% if v.voter_player_id == player.id %}
|
|
||||||
{% set ns_vote.has_voted = True %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if ns_vote.has_voted %}
|
|
||||||
<div class="voted-confirmation-box glass-panel">
|
|
||||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-target="#game-view" hx-swap="innerHTML" class="vote-form inline-form">
|
|
||||||
<div class="form-group inline-group">
|
|
||||||
<select name="nominated_id" class="select-field" required>
|
|
||||||
<option value="" disabled selected>Nominate crewmate...</option>
|
|
||||||
{% for other in game.players if other.id != player.id and other.previous_role != 'deep' %}
|
|
||||||
<option value="{{ other.id }}">{{ other.name }} (Rank {{ other.rank }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-primary">Cast Nomination</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Voting Roster Status -->
|
|
||||||
<div class="vote-status-table margin-top">
|
|
||||||
<h4>Nomination Progress:</h4>
|
|
||||||
<div class="player-chips list-chips" id="voting-roster"
|
|
||||||
hx-get="/game/{{ game.id }}/between/roster"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% set voted = False %}
|
|
||||||
{% for v in votes if v.voter_player_id == p.id %}
|
|
||||||
{% set voted = True %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
<span class="status-badge">{% if voted %}Voted{% else %}Voting...{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resting Deep Player Card -->
|
|
||||||
<div class="card glass-panel deep-rest-card">
|
|
||||||
<h3>2. Resting Deep Upkeep</h3>
|
|
||||||
|
|
||||||
{% if player.previous_role == "deep" %}
|
|
||||||
<div class="deep-rest-panel glass-panel">
|
|
||||||
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size (Rank {{ player.rank }} maximum: {{ max_hand_size }} cards).</p>
|
|
||||||
<p class="footnote-desc">This is handled automatically when transitioning, but you must click 'Ready' below to proceed.</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
|
||||||
<h4>Crew Rank Ledger:</h4>
|
|
||||||
<ul class="ranks-ledger">
|
|
||||||
{% for p in game.players %}
|
|
||||||
<li>
|
|
||||||
<strong>{{ p.name }}:</strong> Rank {{ p.rank }}
|
|
||||||
{% if p.previous_role == 'deep' %}<span class="deep-badge">Deep</span>{% else %}<span class="pirat-badge">Pi-Rat</span>{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next Scene Ready Up -->
|
|
||||||
<div class="action-box margin-top">
|
|
||||||
{% set eligible_count = namespace(value=0) %}
|
|
||||||
{% for other in game.players if other.id != player.id and other.previous_role != 'deep' %}
|
|
||||||
{% set eligible_count.value = eligible_count.value + 1 %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if eligible_count.value > 0 and not ns_vote.has_voted %}
|
|
||||||
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
|
||||||
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
|
||||||
{% else %}
|
|
||||||
{% if player.is_ready %}
|
|
||||||
<div class="waiting-box">
|
|
||||||
<p>Ready! Waiting for other players to ready up...</p>
|
|
||||||
<div class="spinner-small"></div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/ready-next"
|
|
||||||
hx-target="#game-view"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="btn btn-primary btn-large glow-effect">
|
|
||||||
Ready for Next Scene
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<div class="character-creation-view">
|
|
||||||
<div class="view-header text-center">
|
|
||||||
<h2>Character Sheet Creation</h2>
|
|
||||||
<p>Fill in your Rat Records, delegate questions to your crew, and prepare your Math Magic Techniques.</p>
|
|
||||||
<button hx-get="/game/{{ game.id }}/player/{{ player.id }}/view"
|
|
||||||
hx-target="#game-view"
|
|
||||||
class="btn btn-secondary btn-small margin-top">
|
|
||||||
🔄 Refresh Tasks & Sheet
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MAIN PANEL: Grid for Layout -->
|
|
||||||
<div class="creation-grid">
|
|
||||||
|
|
||||||
<!-- SECTION 1: Rat Records (Basic Info) -->
|
|
||||||
<div class="card glass-panel section-basic">
|
|
||||||
<h3>I. Basic Rat Records</h3>
|
|
||||||
|
|
||||||
{% if player.avatar_look and player.avatar_smell and player.first_words and not edit_mode %}
|
|
||||||
<!-- Saved View -->
|
|
||||||
<div class="read-only-sheet">
|
|
||||||
<div class="record-line"><strong>Look:</strong> {{ player.avatar_look }}</div>
|
|
||||||
<div class="record-line"><strong>Smell:</strong> {{ player.avatar_smell }}</div>
|
|
||||||
<div class="record-line"><strong>First Words:</strong> "{{ player.first_words }}"</div>
|
|
||||||
<div class="record-line"><strong>Good at Math?</strong> {{ player.good_at_math }}</div>
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/edit-basic"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-target="#game-view"
|
|
||||||
class="btn btn-secondary btn-small margin-top">Edit Records</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Form View -->
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/save-basic"
|
|
||||||
hx-target="#game-view"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="creation-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="avatar_look" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" value="{{ player.avatar_look }}" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('avatar_look').value = getSuggestion('look');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="avatar_smell" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" value="{{ player.avatar_smell }}" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('avatar_smell').value = getSuggestion('smell');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="first_words">What were your first words after transforming?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="first_words" id="first_words" placeholder="e.g. Where is my gat?!" value="{{ player.first_words }}" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('first_words').value = getSuggestion('first_words');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="good_at_math" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" value="{{ player.good_at_math }}" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('good_at_math').value = getSuggestion('good_at_math');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-full">Save Records</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
|
||||||
<div class="card glass-panel section-delegations">
|
|
||||||
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
|
|
||||||
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations</h3>
|
|
||||||
{% if not player.other_like_from_player_id or not player.other_hate_from_player_id %}
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/delegate/auto"
|
|
||||||
hx-target="#game-view"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="btn btn-secondary btn-small">
|
|
||||||
🎲 Auto-Assign Tasks
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
|
|
||||||
|
|
||||||
<div class="delegation-list">
|
|
||||||
<!-- WHAT OTHERS LIKE -->
|
|
||||||
{% set question_type = 'like' %}
|
|
||||||
{% include 'delegation_snippet.html' %}
|
|
||||||
|
|
||||||
<!-- WHAT OTHERS HATE -->
|
|
||||||
{% set question_type = 'hate' %}
|
|
||||||
{% include 'delegation_snippet.html' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECTION 3: Answer Delegations for Others -->
|
|
||||||
{% include 'inbox_snippet.html' %}
|
|
||||||
|
|
||||||
<!-- SECTION 4: Techniques (Depends on Phase) -->
|
|
||||||
{% include 'techniques_snippet.html' %}
|
|
||||||
|
|
||||||
<!-- SECTION 5: Crew Creation Progress -->
|
|
||||||
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
|
|
||||||
<h3>V. Crew Creation Status</h3>
|
|
||||||
<p class="section-desc">See the creation progress of the entire crew in real time.</p>
|
|
||||||
<div class="crew-status-list" id="crew-status-list"
|
|
||||||
hx-get="/game/{{ game.id }}/player/{{ player.id }}/crew-status"
|
|
||||||
hx-trigger="every 3s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
{% include 'crew_status_snippet.html' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<div class="crew-status-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; width: 100%;">
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% set created_techs = json_loads(p.created_techniques) %}
|
|
||||||
{% set has_submitted_techs = created_techs|length == 3 and created_techs[0] %}
|
|
||||||
|
|
||||||
<div class="player-status-card glass-panel {% if p.id == player.id %}current-user-status{% endif %}"
|
|
||||||
style="padding: 1.25rem; border-left: 4px solid {% if p.id == player.id %}var(--neon-cyan){% else %}rgba(255,255,255,0.15){% endif %}; background: rgba(7, 11, 18, 0.5); border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2);">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 0.5rem;">
|
|
||||||
<strong style="color: var(--gold); font-size: 1.1rem; font-family: var(--font-heading);">
|
|
||||||
🐀 {{ p.name }} {% if p.id == player.id %}(You){% endif %}
|
|
||||||
</strong>
|
|
||||||
<span class="badge" style="font-size: 0.8rem; background: rgba(212,175,55,0.1); border: 1px solid rgba(212,175,55,0.2); padding: 0.15rem 0.4rem; border-radius: 4px; color: var(--gold); font-weight: 700;">
|
|
||||||
Rank {{ p.rank }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-details" style="font-size: 0.9rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
|
||||||
<!-- Basic Records -->
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="color: var(--text-muted);">Basic Records:</span>
|
|
||||||
{% if p.avatar_look and p.avatar_smell and p.first_words %}
|
|
||||||
<span style="color: var(--neon-emerald); font-weight: 700;">✅ Complete</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted); font-style: italic;">✍️ Writing...</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delegations -->
|
|
||||||
<div style="border-top: 1px dashed rgba(255,255,255,0.05); padding-top: 0.4rem;">
|
|
||||||
<span style="color: var(--text-muted); font-weight: 700; display: block; margin-bottom: 0.2rem;">Delegated Tasks:</span>
|
|
||||||
<div style="padding-left: 0.5rem; font-size: 0.85rem; display: flex; flex-direction: column; gap: 0.25rem;">
|
|
||||||
<div style="display: flex; justify-content: space-between;">
|
|
||||||
<span>• Like:</span>
|
|
||||||
{% if p.other_like %}
|
|
||||||
<span style="color: var(--neon-emerald);">Done ({{ get_player_name(p.other_like_from_player_id) }})</span>
|
|
||||||
{% elif p.other_like_from_player_id %}
|
|
||||||
<span style="color: var(--gold);">Waiting on {{ get_player_name(p.other_like_from_player_id) }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted); font-style: italic;">Not delegated</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between;">
|
|
||||||
<span>• Hate:</span>
|
|
||||||
{% if p.other_hate %}
|
|
||||||
<span style="color: var(--neon-emerald);">Done ({{ get_player_name(p.other_hate_from_player_id) }})</span>
|
|
||||||
{% elif p.other_hate_from_player_id %}
|
|
||||||
<span style="color: var(--gold);">Waiting on {{ get_player_name(p.other_hate_from_player_id) }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted); font-style: italic;">Not delegated</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secret Techniques -->
|
|
||||||
<div style="border-top: 1px dashed rgba(255,255,255,0.05); padding-top: 0.4rem; display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="color: var(--text-muted); font-weight: 700;">Secret Techniques:</span>
|
|
||||||
{% if game.phase == "character_creation" %}
|
|
||||||
{% if has_submitted_techs %}
|
|
||||||
<span style="color: var(--neon-emerald); font-weight: 700;">✅ Submitted</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted); font-style: italic;">✍️ Writing...</span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<!-- swap_techniques phase -->
|
|
||||||
{% if p.is_ready %}
|
|
||||||
<span style="color: var(--neon-emerald); font-weight: 700;">✅ J/Q/K Assigned</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted); font-style: italic;">✍️ Assigning...</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %}
|
|
||||||
|
|
||||||
{% block header_extra %}
|
|
||||||
<div id="header-status" class="header-status">
|
|
||||||
{% if player %}
|
|
||||||
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="phase-pill">{{ game.phase.replace('_', ' ').title() }}</span>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Main game container that polls the server for phase changes and state updates -->
|
|
||||||
<div id="game-view"
|
|
||||||
hx-get="/game/{{ game.id }}/player/{{ player.id }}/view"
|
|
||||||
hx-trigger="load, phase-changed from:body"
|
|
||||||
hx-swap="innerHTML scroll:top">
|
|
||||||
<div class="loader-container text-center">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Contacting the math-magic spirits...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Floating Event Log -->
|
|
||||||
<button id="event-log-fab" class="fab event-log-fab" onclick="toggleEventLog()" title="Game Events">
|
|
||||||
📜
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="event-log-panel" class="event-log-panel hidden">
|
|
||||||
<div class="event-log-header">
|
|
||||||
<h3>Game Events</h3>
|
|
||||||
<button onclick="toggleEventLog()" class="btn btn-danger btn-sm">×</button>
|
|
||||||
</div>
|
|
||||||
<div id="event-log-container" class="event-log-container"
|
|
||||||
hx-get="/game/{{ game.id }}/player/{{ player.id }}/events"
|
|
||||||
hx-trigger="every 3s, refresh-events from:body"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center text-muted"><small>Loading events...</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleEventLog() {
|
|
||||||
const panel = document.getElementById('event-log-panel');
|
|
||||||
panel.classList.toggle('hidden');
|
|
||||||
if (!panel.classList.contains('hidden')) {
|
|
||||||
document.body.dispatchEvent(new Event('refresh-events'));
|
|
||||||
// Initially scroll to bottom if opening for the first time
|
|
||||||
setTimeout(() => {
|
|
||||||
const el = document.getElementById('event-log-container');
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll preservation logic
|
|
||||||
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
|
||||||
if (evt.detail.target.id === 'event-log-container') {
|
|
||||||
const el = evt.detail.target;
|
|
||||||
// Only scroll to bottom if they are currently at the bottom (within a small pixel margin)
|
|
||||||
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 25;
|
|
||||||
el.dataset.wasAtBottom = isAtBottom;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
|
||||||
if (evt.detail.target.id === 'event-log-container') {
|
|
||||||
const el = evt.detail.target;
|
|
||||||
if (el.dataset.wasAtBottom === 'true') {
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{% if player.previous_role == "deep" %}
|
|
||||||
<div class="deep-upkeep-view text-center">
|
|
||||||
<h2>Resting Deep Upkeep</h2>
|
|
||||||
<p class="description">Select which cards you want to keep and which you want to discard. Drag cards between the boxes, then click "Confirm Hand Refresh". You will draw back up to your maximum hand size of {{ player.rank + 1 }} cards.</p>
|
|
||||||
|
|
||||||
<div class="upkeep-drag-container">
|
|
||||||
<!-- Keep Box -->
|
|
||||||
<div class="card glass-panel upkeep-box" id="keep-box" ondragover="allowDrop(event)" ondrop="dropToKeep(event)">
|
|
||||||
<h3>Keep (Current Hand)</h3>
|
|
||||||
<div class="hand-flex upkeep-flex" id="keep-list">
|
|
||||||
{% for card in hand %}
|
|
||||||
{% set parsed = parse_card(card) %}
|
|
||||||
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}"
|
|
||||||
draggable="true"
|
|
||||||
ondragstart="drag(event)"
|
|
||||||
id="card-{{ card }}"
|
|
||||||
data-card-code="{{ card }}">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{{ parsed.value }}</span>
|
|
||||||
<span class="suit">{{ parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-center">
|
|
||||||
{% if parsed.is_joker %}
|
|
||||||
🃏
|
|
||||||
{% else %}
|
|
||||||
<span class="giant-symbol">{{ parsed.symbol }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{{ parsed.value }}</span>
|
|
||||||
<span class="suit">{{ parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Discard Box -->
|
|
||||||
<div class="card glass-panel upkeep-box" id="discard-box" ondragover="allowDrop(event)" ondrop="dropToDiscard(event)">
|
|
||||||
<h3 style="color: var(--red-suit);">Discard</h3>
|
|
||||||
<div class="hand-flex upkeep-flex" id="discard-list">
|
|
||||||
<!-- Drop discarded cards here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-box margin-top">
|
|
||||||
{% if player.is_ready %}
|
|
||||||
<div class="waiting-box" style="justify-content: center;">
|
|
||||||
<p>Upkeep confirmed! Waiting for other resting Deep players...</p>
|
|
||||||
<div class="spinner-small"></div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<form id="confirm-refresh-form" hx-post="/game/{{ game.id }}/player/{{ player.id }}/confirm-refresh" hx-target="#game-view" hx-swap="innerHTML">
|
|
||||||
<!-- Hidden input to submit which cards are discarded -->
|
|
||||||
<input type="hidden" name="discard_cards" id="discard-input" value="[]">
|
|
||||||
<button type="submit" class="btn btn-primary btn-large glow-effect">Confirm Hand Refresh</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function allowDrop(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drag(ev) {
|
|
||||||
ev.dataTransfer.setData("text/plain", ev.currentTarget.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dropToKeep(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
var data = ev.dataTransfer.getData("text/plain");
|
|
||||||
var cardEl = document.getElementById(data);
|
|
||||||
if (cardEl) {
|
|
||||||
document.getElementById("keep-list").appendChild(cardEl);
|
|
||||||
updateInputs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dropToDiscard(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
var data = ev.dataTransfer.getData("text/plain");
|
|
||||||
var cardEl = document.getElementById(data);
|
|
||||||
if (cardEl) {
|
|
||||||
document.getElementById("discard-list").appendChild(cardEl);
|
|
||||||
updateInputs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateInputs() {
|
|
||||||
var discardList = document.getElementById("discard-list");
|
|
||||||
var discards = [];
|
|
||||||
for (var i = 0; i < discardList.children.length; i++) {
|
|
||||||
var code = discardList.children[i].getAttribute("data-card-code");
|
|
||||||
if (code) {
|
|
||||||
discards.push(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.getElementById("discard-input").value = JSON.stringify(discards);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% else %}
|
|
||||||
<div class="deep-upkeep-waiting text-center glass-panel" style="max-width:600px; margin:4rem auto; padding:3rem;">
|
|
||||||
<h2>🌊 Resting Deep Upkeep</h2>
|
|
||||||
<p class="description">The players who controlled the Deep in the previous scene are currently refreshing their hands.</p>
|
|
||||||
<div class="waiting-box" style="justify-content: center; margin-top:2rem;">
|
|
||||||
<p>Waiting for resting Deep players to confirm their hand refreshes...</p>
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{% if question_type == 'like' %}
|
|
||||||
{% set ans = player.other_like %}
|
|
||||||
{% set delegate_id = player.other_like_from_player_id %}
|
|
||||||
{% else %}
|
|
||||||
{% set ans = player.other_hate %}
|
|
||||||
{% set delegate_id = player.other_hate_from_player_id %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="delegation-box glass-panel" id="delegation-{{ question_type }}"
|
|
||||||
{% if not ans and delegate_id %}
|
|
||||||
hx-get="/game/{{ game.id }}/player/{{ player.id }}/delegation/{{ question_type }}"
|
|
||||||
hx-trigger="every 4s"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
{% endif %}>
|
|
||||||
|
|
||||||
<h4>What other pirates {{ question_type.upper() }} about you:</h4>
|
|
||||||
|
|
||||||
{% if ans %}
|
|
||||||
<div class="answer-box">"{{ ans }}" <span class="author-label">— answered by {{ get_player_name(delegate_id) }}</span></div>
|
|
||||||
<!-- Button to allow revoking even after answered if they want to override or reassign -->
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/revoke-delegate/{{ question_type }}"
|
|
||||||
hx-target="#delegation-{{ question_type }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn btn-secondary btn-small margin-top-small">Reassign Task</button>
|
|
||||||
{% elif delegate_id %}
|
|
||||||
<div class="waiting-box-container">
|
|
||||||
<div class="waiting-box">
|
|
||||||
<span class="spinner-small"></span>
|
|
||||||
Delegated to <strong>{{ get_player_name(delegate_id) }}</strong>. Waiting...
|
|
||||||
</div>
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/revoke-delegate/{{ question_type }}"
|
|
||||||
hx-target="#delegation-{{ question_type }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn btn-danger btn-small margin-top-small">Revoke Task</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/delegate/{{ question_type }}"
|
|
||||||
hx-target="#delegation-{{ question_type }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="delegate-form inline-form">
|
|
||||||
<select name="target_player_id" class="select-field select-small" required>
|
|
||||||
<option value="" disabled selected>Select a crewmate...</option>
|
|
||||||
{% for other in game.players if other.id != player.id %}
|
|
||||||
<option value="{{ other.id }}">{{ other.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-secondary btn-small">Ask them</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{% if events %}
|
|
||||||
<ul class="event-list">
|
|
||||||
{% for event in events %}
|
|
||||||
<li class="event-item">
|
|
||||||
<span class="event-time" data-timestamp="{{ event.timestamp }}"></span>
|
|
||||||
<span class="event-msg">{{ event.message }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.event-time[data-timestamp]').forEach(el => {
|
|
||||||
if (!el.textContent) {
|
|
||||||
const ts = parseFloat(el.dataset.timestamp);
|
|
||||||
const d = new Date(ts * 1000);
|
|
||||||
el.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted"><small>No events yet.</small></div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<div id="header-status" hx-swap-oob="true" class="header-status">
|
|
||||||
{% if player %}
|
|
||||||
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="phase-pill">{{ game.phase.replace('_', ' ').title() }}</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<div class="card glass-panel section-inbox" id="inbox-card">
|
|
||||||
<h3>III. Your Tasks for Others</h3>
|
|
||||||
<p class="section-desc">Answer questions that your crewmates have delegated to you.</p>
|
|
||||||
|
|
||||||
<div class="inbox-list">
|
|
||||||
<p class="empty-text text-center inbox-empty-message">No tasks in your inbox. Check back when other players delegate to you!</p>
|
|
||||||
{% if inbox_tasks %}
|
|
||||||
{% for task in inbox_tasks %}
|
|
||||||
{% set p = task.player %}
|
|
||||||
{% if task.type == 'like' %}
|
|
||||||
<div class="inbox-item glass-panel" id="inbox-item-like-{{ p.id }}">
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/like"
|
|
||||||
hx-target="#inbox-item-like-{{ p.id }}"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
<label>What do you <strong>LIKE</strong> about <strong>{{ p.name }}</strong>?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="answer" id="inbox-like-{{ p.id }}" placeholder="e.g. He always shares his cheese" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('inbox-like-{{ p.id }}').value = getSuggestion('like');">Suggest</button>
|
|
||||||
<button type="submit" class="btn btn-primary btn-small">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="inbox-item glass-panel" id="inbox-item-hate-{{ p.id }}">
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/hate"
|
|
||||||
hx-target="#inbox-item-hate-{{ p.id }}"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
<label>What do you <strong>HATE</strong> about <strong>{{ p.name }}</strong>?</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="answer" id="inbox-hate-{{ p.id }}" placeholder="e.g. He chews his tail when nervous" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('inbox-hate-{{ p.id }}').value = getSuggestion('hate');">Suggest</button>
|
|
||||||
<button type="submit" class="btn btn-primary btn-small">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Welcome - Rats with Gats{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="welcome-container glass-panel">
|
|
||||||
<div class="welcome-hero text-center">
|
|
||||||
<h2>Steal Ships. Commit Piracy. Run on Math Magic.</h2>
|
|
||||||
<p class="subtitle">A collaborative storytelling web-app to facilitate remote play of the whimsical roleplaying game.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-actions">
|
|
||||||
<div class="action-card glass-panel text-center">
|
|
||||||
<h3>Start a New Adventure</h3>
|
|
||||||
<p>Generate a fresh game deck and name your crew of mathematical pirates.</p>
|
|
||||||
<form action="/game/create" method="POST">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" name="crew_name" placeholder="Name your Crew (e.g. The Math-Gat Rats)" required class="input-field text-center">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-large glow-effect">Create Game</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-card glass-panel text-center">
|
|
||||||
<h3>Join an Existing Crew</h3>
|
|
||||||
<p>Enter a Game ID or Join Link provided by your Captain.</p>
|
|
||||||
<form onsubmit="event.preventDefault(); window.location.href = '/game/' + document.getElementById('game-id-input').value.trim() + '/join';">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="game-id-input" placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000" required class="input-field text-center">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-secondary btn-large">Join Game</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Join Crew - Rats with Gats{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="join-container glass-panel text-center">
|
|
||||||
<h2>Join the {% if game.crew_name %}{{ game.crew_name }}{% else %}Pi-Rat{% endif %} Crew!</h2>
|
|
||||||
<p>The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.</p>
|
|
||||||
|
|
||||||
<form action="/game/{{ game.id }}/join" method="POST" class="join-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name-input">Your Stowaway Name:</label>
|
|
||||||
<input type="text" id="name-input" name="name" placeholder="e.g. Whisker-Face" required class="input-field text-center" autofocus>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-large glow-effect">Transform into a Pi-Rat!</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="back-link">
|
|
||||||
<a href="/">Back to Main Menu</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<div class="lobby-view text-center">
|
|
||||||
<h2>{% if game.crew_name %}{{ game.crew_name }}{% else %}Ship's Hold{% endif %} (Lobby)</h2>
|
|
||||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
|
||||||
|
|
||||||
<div class="lobby-status-box glass-panel">
|
|
||||||
<h3>Joined Crew members</h3>
|
|
||||||
<div class="player-chips" id="lobby-player-list"
|
|
||||||
hx-get="/game/{{ game.id }}/lobby/players?player_id={{ player.id }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
{% for p in game.players %}
|
|
||||||
<div class="player-chip {% if p.id == player.id %}current-user{% endif %}">
|
|
||||||
<span class="avatar-icon">🐀</span>
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="links-box glass-panel">
|
|
||||||
<div class="link-item">
|
|
||||||
<h4>Share Join Link:</h4>
|
|
||||||
<div class="copy-container">
|
|
||||||
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/join" class="copy-input" id="join-link-copy">
|
|
||||||
<button onclick="navigator.clipboard.writeText(document.getElementById('join-link-copy').value); alert('Join link copied!');" class="btn btn-secondary btn-small">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if player.is_creator %}
|
|
||||||
<div class="link-item admin-link-item">
|
|
||||||
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
|
|
||||||
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
|
||||||
<div class="copy-container">
|
|
||||||
<input type="text" readonly value="{{ base_url }}/game/{{ game.id }}/admin?key={{ game.admin_key }}" class="copy-input admin-input" id="admin-link-copy">
|
|
||||||
<button onclick="navigator.clipboard.writeText(document.getElementById('admin-link-copy').value); alert('Admin link copied!');" class="btn btn-gold btn-small">Copy Admin Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lobby-action">
|
|
||||||
{% if player.is_creator %}
|
|
||||||
{% if game.players|length >= 1 %}
|
|
||||||
<button hx-post="/game/{{ game.id }}/lobby/start"
|
|
||||||
hx-swap="none"
|
|
||||||
class="btn btn-primary btn-large glow-effect">
|
|
||||||
Start Character Creation
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button class="btn btn-primary btn-large" disabled>Waiting for players...</button>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<div class="waiting-indicator">
|
|
||||||
<div class="spinner-small"></div>
|
|
||||||
<p>Waiting for Captain to cast the spell...</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{% for p in game.players %}
|
|
||||||
<div class="player-chip {% if p.id == player_id %}current-user{% endif %}">
|
|
||||||
<span class="avatar-icon">🐀</span>
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
{% if p.is_creator %}<span class="creator-badge">Creator</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
|
||||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
|
||||||
<!-- Captain Badge -->
|
|
||||||
{% set ns = namespace(captain=None) %}
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if is_captain(p, game.players) %}
|
|
||||||
{% set ns.captain = p %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div>
|
|
||||||
{% if ns.captain %}
|
|
||||||
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }})
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: None (No Pi-Rats)
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Scene Roster Mini-list -->
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
|
||||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if p.role == 'deep' %}
|
|
||||||
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
🌊 {{ p.name }} (Deep)
|
|
||||||
</span>
|
|
||||||
{% elif p.role == 'pirat' %}
|
|
||||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {% if is_captain(p, game.players) %}border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);{% endif %}">
|
|
||||||
🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for obs in game.obstacles %}
|
|
||||||
{% set column_cards = json_loads(obs.played_cards) %}
|
|
||||||
{% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %}
|
|
||||||
{% set active_parsed = parse_card(active_card_code) %}
|
|
||||||
<div class="obstacle-item suit-{{ obs.suit.lower() }}"
|
|
||||||
{% if player.role != "deep" %}
|
|
||||||
data-obstacle-id="{{ obs.id }}"
|
|
||||||
ondragover="dragOver(event)"
|
|
||||||
ondragenter="dragEnter(event)"
|
|
||||||
ondragleave="dragLeave(event)"
|
|
||||||
ondrop="dropCard(event)"
|
|
||||||
{% endif %}>
|
|
||||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
|
||||||
<div class="card-medium suit-{{ active_parsed.suit.lower() }} {% if active_parsed.is_joker %}joker-card{% endif %}" title="Active Card: {{ active_parsed.display }}">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{{ active_parsed.value }}</span>
|
|
||||||
<span class="suit">{{ active_parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-center">
|
|
||||||
{% if active_parsed.is_joker %}
|
|
||||||
🃏
|
|
||||||
{% else %}
|
|
||||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{ active_parsed.symbol }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{{ active_parsed.value }}</span>
|
|
||||||
<span class="suit">{{ active_parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="obstacle-details">
|
|
||||||
<h4>{{ obs.title }}</h4>
|
|
||||||
<p class="desc">{{ obs.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Value Display -->
|
|
||||||
<div class="obstacle-value-display text-center">
|
|
||||||
<span class="val-label">Current Difficulty</span>
|
|
||||||
<span class="val-number">
|
|
||||||
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
|
|
||||||
Rank ({{ player.rank }})
|
|
||||||
{% else %}
|
|
||||||
{{ obs.current_value }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Successes Tracker -->
|
|
||||||
{% set ns_non_deep = namespace(count=0) %}
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if p.role != "deep" %}
|
|
||||||
{% set ns_non_deep.count = ns_non_deep.count + 1 %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="obstacle-successes-display text-center">
|
|
||||||
<span class="val-label">Successes</span>
|
|
||||||
<span class="val-number" style="font-size: 1.5rem;">
|
|
||||||
{{ obs.success_count }} / {{ ns_non_deep.count }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Played Cards Column -->
|
|
||||||
<div class="played-column">
|
|
||||||
<h5>Card History (Column)</h5>
|
|
||||||
{% set column_cards = json_loads(obs.played_cards) %}
|
|
||||||
<div class="column-cards-flex">
|
|
||||||
<div class="card-mini base-card">
|
|
||||||
<span class="val">{{ obs.original_card[:-1] }}</span>
|
|
||||||
<span class="suit">{{ obs.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
{% for pc in column_cards %}
|
|
||||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom: 0.25rem;">
|
|
||||||
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
|
|
||||||
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
|
|
||||||
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
|
|
||||||
<span class="suit">{{ parse_card(pc.card).symbol }}</span>
|
|
||||||
<span class="owner">{{ pc.player_name[:4] }}</span>
|
|
||||||
</div>
|
|
||||||
{% if loop.last and player.role == 'deep' %}
|
|
||||||
<button hx-post="/game/{{ game.id }}/scene/rollback"
|
|
||||||
hx-vals='{"obstacle_id": "{{ obs.id }}"}'
|
|
||||||
hx-swap="none"
|
|
||||||
title="Roll back this card play"
|
|
||||||
style="background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.2rem; padding: 0;">
|
|
||||||
↩️
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
|
||||||
{% endfor %}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id="{{ game.id }}" data-player-id="{{ player.id }}">
|
|
||||||
<!-- LEFT COLUMN: The Shared Table -->
|
|
||||||
<div class="table-column">
|
|
||||||
|
|
||||||
<!-- Game Deck and Obstacles Roster -->
|
|
||||||
<div class="card glass-panel obstacle-list-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>🌊 The Obstacle List</h3>
|
|
||||||
<span class="deck-counter">🎴 Deck: {{ deck_count }} cards left</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="obstacles-container" id="scene-obstacles-container"
|
|
||||||
hx-get="/game/{{ game.id }}/scene/obstacles?player_id={{ player.id }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
|
||||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
|
||||||
<!-- Captain Badge -->
|
|
||||||
{% set ns = namespace(captain=None) %}
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if is_captain(p, game.players) %}
|
|
||||||
{% set ns.captain = p %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div>
|
|
||||||
{% if ns.captain %}
|
|
||||||
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }})
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
🏴☠️ Captain: None (No Pi-Rats)
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Scene Roster Mini-list -->
|
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
|
||||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if p.role == 'deep' %}
|
|
||||||
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
🌊 {{ p.name }} (Deep)
|
|
||||||
</span>
|
|
||||||
{% elif p.role == 'pirat' %}
|
|
||||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {% if is_captain(p, game.players) %}border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);{% endif %}">
|
|
||||||
🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for obs in game.obstacles %}
|
|
||||||
{% set column_cards = json_loads(obs.played_cards) %}
|
|
||||||
{% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %}
|
|
||||||
{% set active_parsed = parse_card(active_card_code) %}
|
|
||||||
<div class="obstacle-item suit-{{ obs.suit.lower() }}"
|
|
||||||
{% if player.role != "deep" %}
|
|
||||||
data-obstacle-id="{{ obs.id }}"
|
|
||||||
ondragover="dragOver(event)"
|
|
||||||
ondragenter="dragEnter(event)"
|
|
||||||
ondragleave="dragLeave(event)"
|
|
||||||
ondrop="dropCard(event)"
|
|
||||||
{% endif %}>
|
|
||||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
|
||||||
<div class="card-medium suit-{{ active_parsed.suit.lower() }} {% if active_parsed.is_joker %}joker-card{% endif %}" title="Active Card: {{ active_parsed.display }}">
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{{ active_parsed.value }}</span>
|
|
||||||
<span class="suit">{{ active_parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-center">
|
|
||||||
{% if active_parsed.is_joker %}
|
|
||||||
🃏
|
|
||||||
{% else %}
|
|
||||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{ active_parsed.symbol }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{{ active_parsed.value }}</span>
|
|
||||||
<span class="suit">{{ active_parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="obstacle-details">
|
|
||||||
<h4>{{ obs.title }}</h4>
|
|
||||||
<p class="desc">{{ obs.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Value Display -->
|
|
||||||
<div class="obstacle-value-display text-center">
|
|
||||||
<span class="val-label">Current Difficulty</span>
|
|
||||||
<span class="val-number">
|
|
||||||
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
|
|
||||||
Rank ({{ player.rank }})
|
|
||||||
{% else %}
|
|
||||||
{{ obs.current_value }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Successes Tracker -->
|
|
||||||
{% set ns_non_deep = namespace(count=0) %}
|
|
||||||
{% for p in game.players %}
|
|
||||||
{% if p.role != "deep" %}
|
|
||||||
{% set ns_non_deep.count = ns_non_deep.count + 1 %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="obstacle-successes-display text-center">
|
|
||||||
<span class="val-label">Successes</span>
|
|
||||||
<span class="val-number" style="font-size: 1.5rem;">
|
|
||||||
{{ obs.success_count }} / {{ ns_non_deep.count }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Played Cards Column -->
|
|
||||||
<div class="played-column">
|
|
||||||
<h5>Card History (Column)</h5>
|
|
||||||
{% set column_cards = json_loads(obs.played_cards) %}
|
|
||||||
<div class="column-cards-flex">
|
|
||||||
<div class="card-mini base-card">
|
|
||||||
<span class="val">{{ obs.original_card[:-1] }}</span>
|
|
||||||
<span class="suit">{{ obs.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
{% for pc in column_cards %}
|
|
||||||
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
|
|
||||||
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
|
|
||||||
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
|
|
||||||
<span class="suit">{{ parse_card(pc.card).symbol }}</span>
|
|
||||||
<span class="owner">{{ pc.player_name[:4] }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
|
||||||
<div class="private-column">
|
|
||||||
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
|
||||||
{% if player.role == "deep" %}
|
|
||||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
|
||||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
|
||||||
<div class="objectives-checklist">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_1" hx-trigger="click" hx-swap="none" {% if game.completed_crew_1 %}checked{% endif %}>
|
|
||||||
<span>Steal a Ship</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_2" hx-trigger="click" hx-swap="none" {% if game.completed_crew_2 %}checked{% endif %}>
|
|
||||||
<span>Choose a Captain</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_3" hx-trigger="click" hx-swap="none" {% if game.completed_crew_3 %}checked{% endif %}>
|
|
||||||
<span>Commit Piracy</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
|
|
||||||
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
|
|
||||||
{% for p in game.players if p.role == 'pirat' %}
|
|
||||||
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
|
|
||||||
<strong>{{ p.name }}</strong>
|
|
||||||
<div class="objectives-checklist" style="margin-top: 0.25rem;">
|
|
||||||
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_1" hx-trigger="click" hx-swap="none" {% if p.completed_personal_1 %}checked{% endif %}> <span>1. Gat</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_2" hx-trigger="click" hx-swap="none" {% if p.completed_personal_2 %}checked{% endif %}> <span>2. Name</span></label>
|
|
||||||
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_3" hx-trigger="click" hx-swap="none" {% if p.completed_personal_3 %}checked{% endif %}> <span>3. Die/Retire</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End Scene Button -->
|
|
||||||
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
|
||||||
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
|
||||||
<button hx-post="/game/{{ game.id }}/scene/end"
|
|
||||||
hx-swap="none"
|
|
||||||
class="btn btn-danger btn-large btn-full glow-effect">
|
|
||||||
End Scene & Proceed to Ranking
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Player Hand Card -->
|
|
||||||
<div class="card glass-panel hand-card">
|
|
||||||
<h3>🃏 Your Hand</h3>
|
|
||||||
<p class="section-desc">Keep your cards secret! {% if player.role != "deep" %}Drag a card onto an active obstacle to play it.{% else %}Max hand size: {{ max_hand_size }} cards.{% endif %}</p>
|
|
||||||
|
|
||||||
<div class="hand-flex">
|
|
||||||
{% for card in hand %}
|
|
||||||
{% set parsed = parse_card(card) %}
|
|
||||||
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}"
|
|
||||||
{% if player.role != "deep" %}
|
|
||||||
draggable="true"
|
|
||||||
ondragstart="dragStart(event)"
|
|
||||||
ondragend="dragEnd(event)"
|
|
||||||
data-card-code="{{ card }}"
|
|
||||||
{% endif %}>
|
|
||||||
<div class="card-corner top-left">
|
|
||||||
<span class="val">{{ parsed.value }}</span>
|
|
||||||
<span class="suit">{{ parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-center">
|
|
||||||
{% if parsed.is_joker %}
|
|
||||||
🃏
|
|
||||||
{% else %}
|
|
||||||
<span class="giant-symbol">{{ parsed.symbol }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Technique Helper on Card Face -->
|
|
||||||
<div class="card-tech-overlay">
|
|
||||||
{% if parsed.value == "J" %}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">J: "{{ player.tech_jack }}"</div>
|
|
||||||
{% elif parsed.value == "Q" %}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">Q: "{{ player.tech_queen }}"</div>
|
|
||||||
{% elif parsed.value == "K" %}
|
|
||||||
<div class="tech-tag" title="Automatic success when played">K: "{{ player.tech_king }}"</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
|
||||||
<span class="val">{{ parsed.value }}</span>
|
|
||||||
<span class="suit">{{ parsed.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player Character Sheet Card -->
|
|
||||||
{% if player.role != "deep" %}
|
|
||||||
<div class="card glass-panel sheet-card">
|
|
||||||
<h3>🐀 Your Character sheet</h3>
|
|
||||||
|
|
||||||
<div class="character-sheet-details">
|
|
||||||
<div class="sheet-group">
|
|
||||||
<h4>Description:</h4>
|
|
||||||
<p><strong>Look:</strong> {{ player.avatar_look }}</p>
|
|
||||||
<p><strong>Smell:</strong> {{ player.avatar_smell }}</p>
|
|
||||||
<p><strong>First Words:</strong> "{{ player.first_words }}"</p>
|
|
||||||
<p><strong>Likes:</strong> "{{ player.other_like }}" <span class="footnote">(by {{ get_player_name(player.other_like_from_player_id) }})</span></p>
|
|
||||||
<p><strong>Hates:</strong> "{{ player.other_hate }}" <span class="footnote">(by {{ get_player_name(player.other_hate_from_player_id) }})</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
|
||||||
<ul class="techniques-list-sheet">
|
|
||||||
<li><strong>Jack (J):</strong> "{{ player.tech_jack }}"</li>
|
|
||||||
<li><strong>Queen (Q):</strong> "{{ player.tech_queen }}"</li>
|
|
||||||
<li><strong>King (K):</strong> "{{ player.tech_king }}"</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sheet-group margin-top">
|
|
||||||
<h4>Objectives Tracker:</h4>
|
|
||||||
<p class="info-text">Deep players control these objectives. They unlock privileges when checked.</p>
|
|
||||||
<div class="objectives-checklist">
|
|
||||||
<div style="margin-bottom: 0.5rem;">
|
|
||||||
<span>{% if player.completed_personal_1 %}✅{% else %}⬜{% endif %} 🔫 Personal 1: Get a Gat</span>
|
|
||||||
<div class="footnote-desc">Privilege: +1 to Black cards. Tax: Can ask a Gatted player to do challenges.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 0.5rem;">
|
|
||||||
<span>{% if player.completed_personal_2 %}✅{% else %}⬜{% endif %} 👑 Personal 2: Earn a Name</span>
|
|
||||||
<div class="footnote-desc">Privilege: +1 to Red cards. Tax: Can ask a Named player to do challenges.</div>
|
|
||||||
{% if player.needs_name %}
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-name" hx-swap="none" style="margin-top: 0.5rem; display: flex; gap: 0.5rem;">
|
|
||||||
<input type="text" name="new_name" class="input-field" placeholder="Enter new proper name" required>
|
|
||||||
<button type="submit" class="btn btn-primary">Claim Name</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 0.5rem;">
|
|
||||||
<span>{% if player.completed_personal_3 %}✅{% else %}⬜{% endif %} ☠️ Personal 3: Die Like a Pirate</span>
|
|
||||||
<div class="footnote-desc">Bonus: Rank up another player.</div>
|
|
||||||
|
|
||||||
{% if player.needs_rank_3_bonus %}
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/bonus-rank-up" hx-swap="none" style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; background: rgba(212,175,55,0.1); padding: 0.5rem; border: 1px solid var(--gold); border-radius: 4px;">
|
|
||||||
<label>Choose a Pi-Rat to gain 1 Rank:</label>
|
|
||||||
<select name="target_player_id" class="input-field" required>
|
|
||||||
<option value="" disabled selected>Select player...</option>
|
|
||||||
{% for p in game.players if p.role == 'pirat' and p.id != player.id %}
|
|
||||||
<option value="{{ p.id }}">{{ p.name }} (Rank {{ p.rank }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-primary">Bestow Rank & Pass On</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if player.is_dead and not player.is_ghost %}
|
|
||||||
<div style="margin-top: 0.5rem; background: rgba(255,100,100,0.1); padding: 0.5rem; border: 1px solid red; border-radius: 4px; text-align: center;">
|
|
||||||
<h4 style="color: red; margin:0 0 0.5rem 0;">You Died!</h4>
|
|
||||||
<p class="info-text">Your Pi-Rat's story has concluded. You may continue to watch over your crew as a ghost!</p>
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/become-ghost" hx-swap="none" class="btn btn-secondary">Become a Ghost</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if player.is_ghost %}
|
|
||||||
<div style="margin-top: 0.5rem; text-align: center;">
|
|
||||||
<span style="display:inline-block; padding: 0.25rem 0.5rem; background: rgba(255,255,255,0.1); border-radius: 12px; font-weight: bold;">👻 Ghost Mode Active</span>
|
|
||||||
<p class="footnote-desc">You can still use your hand to help the living, but between scenes you may roll a new recruit.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}&rank={{ player.rank }}&needs_name={{ player.needs_name }}&hand_len={{ hand|length }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.dragStart = function(ev) {
|
|
||||||
ev.dataTransfer.setData("text/plain", ev.currentTarget.getAttribute("data-card-code"));
|
|
||||||
ev.currentTarget.classList.add("dragging");
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dragEnd = function(ev) {
|
|
||||||
ev.currentTarget.classList.remove("dragging");
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dragOver = function(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dragEnter = function(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const item = ev.currentTarget.closest(".obstacle-item");
|
|
||||||
if (item) {
|
|
||||||
item.classList.add("drag-over");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dragLeave = function(ev) {
|
|
||||||
const item = ev.currentTarget.closest(".obstacle-item");
|
|
||||||
if (item) {
|
|
||||||
item.classList.remove("drag-over");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dropCard = function(ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const item = ev.currentTarget.closest(".obstacle-item");
|
|
||||||
if (item) {
|
|
||||||
item.classList.remove("drag-over");
|
|
||||||
const cardCode = ev.dataTransfer.getData("text/plain");
|
|
||||||
const obstacleId = item.getAttribute("data-obstacle-id");
|
|
||||||
if (cardCode && obstacleId) {
|
|
||||||
const container = document.getElementById("scene-layout-container");
|
|
||||||
if (container) {
|
|
||||||
const gameId = container.getAttribute("data-game-id");
|
|
||||||
const playerId = container.getAttribute("data-player-id");
|
|
||||||
|
|
||||||
const isJoker = cardCode.startsWith("Joker");
|
|
||||||
const url = isJoker
|
|
||||||
? `/game/${gameId}/player/${playerId}/play-joker`
|
|
||||||
: `/game/${gameId}/player/${playerId}/play-card`;
|
|
||||||
|
|
||||||
// Find the dragged card element before the request
|
|
||||||
const draggedCard = document.querySelector(
|
|
||||||
`.card-large[data-card-code="${cardCode}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
htmx.ajax('POST', url, {
|
|
||||||
values: { obstacle_id: obstacleId, card_code: cardCode },
|
|
||||||
target: 'body',
|
|
||||||
swap: 'beforeend'
|
|
||||||
}).then(function() {
|
|
||||||
// Remove the card from the hand on success
|
|
||||||
if (draggedCard && draggedCard.parentNode) {
|
|
||||||
draggedCard.remove();
|
|
||||||
}
|
|
||||||
// Check if hand is now empty (and no card was drawn)
|
|
||||||
const handFlex = document.querySelector('.hand-flex');
|
|
||||||
if (handFlex && handFlex.children.length === 0) {
|
|
||||||
handFlex.innerHTML = '<p class="empty-text text-center">Your hand is empty! (You draw cards when playing cards that match the suit color of the obstacle.)</p>';
|
|
||||||
}
|
|
||||||
// Auto-dismiss the notification after 3 seconds
|
|
||||||
const notif = document.querySelector('.play-notification:last-of-type');
|
|
||||||
if (notif) {
|
|
||||||
setTimeout(function() { notif.remove(); }, 3000);
|
|
||||||
// If a card was drawn, refresh the full view to show it
|
|
||||||
if (notif.getAttribute('data-drew') === 'true') {
|
|
||||||
htmx.trigger(document.body, 'phase-changed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<div class="roster-section">
|
|
||||||
<h4>🌊 The Deep (NPCs & Hazards)</h4>
|
|
||||||
<div class="player-chips list-chips">
|
|
||||||
{% for p in game.players if p.role == 'deep' %}
|
|
||||||
<div class="player-chip deep-chip">
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
<span class="sub-label">Rank {{ p.rank }}</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text">None selected</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="roster-section margin-top">
|
|
||||||
<h4>🐀 Pi-Rats (Crew Players)</h4>
|
|
||||||
<div class="player-chips list-chips">
|
|
||||||
{% for p in game.players if p.role == 'pirat' %}
|
|
||||||
<div class="player-chip pirat-chip">
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
<span class="sub-label">Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text">None selected</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="roster-section margin-top">
|
|
||||||
<h4>⏳ Yet to Choose</h4>
|
|
||||||
<div class="player-chips list-chips">
|
|
||||||
{% for p in game.players if not p.role %}
|
|
||||||
<div class="player-chip undecided-chip">
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
<span class="sub-label">Rank {{ p.rank }}</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-text">Everyone has chosen!</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<div class="scene-setup-view text-center">
|
|
||||||
<h2>Scene Setup (Scene #{{ game.current_scene_number }})</h2>
|
|
||||||
<p class="description">Select your role for the upcoming scene. There must be at least one Pi-Rat and one Deep player. If you played the Deep in the last scene, you must play a Pi-Rat this scene!</p>
|
|
||||||
|
|
||||||
<div class="setup-grid">
|
|
||||||
<!-- Role Selection Card -->
|
|
||||||
<div class="card glass-panel role-selection-card">
|
|
||||||
<h3>Select Your Role</h3>
|
|
||||||
|
|
||||||
{% set force_pirat = False %}
|
|
||||||
{% set locked_role = None %}
|
|
||||||
|
|
||||||
<!-- First scene rules -->
|
|
||||||
{% if game.current_scene_number == 1 %}
|
|
||||||
{% if player.rank == 3 %}
|
|
||||||
{% set locked_role = "deep" %}
|
|
||||||
{% elif player.rank == 1 %}
|
|
||||||
{% set locked_role = "pirat" %}
|
|
||||||
{% endif %}
|
|
||||||
<!-- Normal scene rules -->
|
|
||||||
{% else %}
|
|
||||||
{% if player.previous_role == "deep" %}
|
|
||||||
{% set force_pirat = True %}
|
|
||||||
{% set locked_role = "pirat" %}
|
|
||||||
{% elif eligible_deeps|length == 1 %}
|
|
||||||
{% set locked_role = "deep" %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if locked_role %}
|
|
||||||
<div class="locked-role-box glass-panel text-center">
|
|
||||||
<p class="info-text">
|
|
||||||
{% if game.current_scene_number == 1 %}
|
|
||||||
Based on starting Ranks, you are locked into:
|
|
||||||
{% elif player.previous_role == "deep" %}
|
|
||||||
You were Deep in the previous scene! You are locked into:
|
|
||||||
{% else %}
|
|
||||||
You are the only player eligible to play the Deep! You are locked into:
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="role-badge large-badge {{ locked_role }}">
|
|
||||||
{{ locked_role.upper() }}
|
|
||||||
</div>
|
|
||||||
<!-- Silently auto-select this role in the database using HTMX onload if not already set -->
|
|
||||||
<div hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role={{ locked_role }}" hx-trigger="load" hx-swap="none" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="role-buttons">
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=pirat"
|
|
||||||
hx-target="#game-view"
|
|
||||||
onclick="const p=this.closest('.role-buttons'); if(p) p.querySelectorAll('.role-btn').forEach(b=>b.classList.remove('active')); this.classList.add('active');"
|
|
||||||
class="btn btn-large role-btn pirat-btn {% if player.role == 'pirat' %}active{% endif %}">
|
|
||||||
🐀 Play Pi-Rat
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=deep"
|
|
||||||
hx-target="#game-view"
|
|
||||||
onclick="const p=this.closest('.role-buttons'); if(p) p.querySelectorAll('.role-btn').forEach(b=>b.classList.remove('active')); this.classList.add('active');"
|
|
||||||
class="btn btn-large role-btn deep-btn {% if player.role == 'deep' %}active{% endif %}">
|
|
||||||
🌊 Play the Deep
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live Roster Card -->
|
|
||||||
<div class="card glass-panel roster-card">
|
|
||||||
<h3>Live Roster Selection</h3>
|
|
||||||
<div class="roster-list" id="scene-roster-list"
|
|
||||||
hx-get="/game/{{ game.id }}/scene/roster"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
{% include "scene_roster_snippet.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-box margin-top">
|
|
||||||
{% if error_msg %}
|
|
||||||
<div class="alert alert-danger">{{ error_msg }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Any player can attempt to start the scene, but validation will run -->
|
|
||||||
<button hx-post="/game/{{ game.id }}/scene/start"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-target="#game-view"
|
|
||||||
class="btn btn-primary btn-large glow-effect">
|
|
||||||
Confirm Roles & Shuffle Deck
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden phase-check trigger -->
|
|
||||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
|
||||||
hx-trigger="every 2s"
|
|
||||||
hx-swap="none"
|
|
||||||
style="display:none;"></div>
|
|
||||||
|
|
||||||
{% include "header_status_snippet.html" %}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
<div class="card glass-panel section-techniques" id="techniques-card">
|
|
||||||
{% if game.phase == "character_creation" %}
|
|
||||||
<h3>IV. Create 3 Secret Techniques</h3>
|
|
||||||
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
|
||||||
|
|
||||||
{% set created_techs = json_loads(player.created_techniques) %}
|
|
||||||
{% if created_techs|length == 3 and created_techs[0] %}
|
|
||||||
<div class="submitted-techniques text-center">
|
|
||||||
<div class="tech-chip">#1: {{ created_techs[0] }}</div>
|
|
||||||
<div class="tech-chip">#2: {{ created_techs[1] }}</div>
|
|
||||||
<div class="tech-chip">#3: {{ created_techs[2] }}</div>
|
|
||||||
<p class="waiting-box margin-top">
|
|
||||||
<span class="spinner-small"></span>
|
|
||||||
Techniques submitted! Waiting for other players to submit so we can shuffle and swap them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Creation Form -->
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-techniques"
|
|
||||||
hx-target="#techniques-card"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
id="tech-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="tech1">Technique #1:</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="tech1" id="tech1" placeholder="e.g. Carlo's cheese shield" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech1').value = getSuggestion('techniques');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="tech2">Technique #2:</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="tech2" id="tech2" placeholder="e.g. Parabolic boarding bounce" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech2').value = getSuggestion('techniques');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="tech3">Technique #3:</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" name="tech3" id="tech3" placeholder="e.g. Look, a three-headed gull!" required class="input-field">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" onclick="document.getElementById('tech3').value = getSuggestion('techniques');">Suggest</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary btn-full glow-effect">Submit Techniques to Swap Pool</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% elif game.phase == "swap_techniques" %}
|
|
||||||
<h3>IV. Assign Swapped Secret Techniques</h3>
|
|
||||||
<p class="section-desc">You received these techniques from your crew. Assign one to Jack, Queen, and King cards.</p>
|
|
||||||
|
|
||||||
{% set swapped_techs = json_loads(player.swapped_techniques) %}
|
|
||||||
|
|
||||||
{% if player.is_ready %}
|
|
||||||
<div class="assigned-techs text-center">
|
|
||||||
<div class="tech-assignment-pill"><span class="card-rank">J</span> {{ player.tech_jack }}</div>
|
|
||||||
<div class="tech-assignment-pill"><span class="card-rank">Q</span> {{ player.tech_queen }}</div>
|
|
||||||
<div class="tech-assignment-pill"><span class="card-rank">K</span> {{ player.tech_king }}</div>
|
|
||||||
<p class="waiting-box margin-top">
|
|
||||||
<span class="spinner-small"></span>
|
|
||||||
Ready! Waiting for other players to finish J/Q/K assignment...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/assign-face-techniques"
|
|
||||||
hx-target="#techniques-card"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
id="face-tech-form"
|
|
||||||
class="face-tech-drag-form">
|
|
||||||
|
|
||||||
<!-- Hidden inputs to submit assigned values -->
|
|
||||||
<input type="hidden" name="jack" id="input-jack" value="">
|
|
||||||
<input type="hidden" name="queen" id="input-queen" value="">
|
|
||||||
<input type="hidden" name="king" id="input-king" value="">
|
|
||||||
|
|
||||||
<!-- Available Techniques Pool -->
|
|
||||||
<div class="available-techniques-container">
|
|
||||||
<h4>Available Swapped Techniques</h4>
|
|
||||||
<p class="instruction-help">Drag a technique onto a card slot below, or click/tap a technique and then click/tap a slot to assign it.</p>
|
|
||||||
<div class="techniques-drag-pool" id="tech-pool">
|
|
||||||
{% for t in swapped_techs %}
|
|
||||||
<div class="draggable-tech-chip"
|
|
||||||
draggable="true"
|
|
||||||
data-tech="{{ t }}"
|
|
||||||
id="tech-chip-{{ loop.index0 }}">
|
|
||||||
<span class="drag-icon">⋮⋮</span> {{ t }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Visual Card Slots Grid -->
|
|
||||||
<div class="face-cards-slots-grid">
|
|
||||||
|
|
||||||
<!-- JACK SLOT -->
|
|
||||||
<div class="face-card-slot-wrapper">
|
|
||||||
<div class="face-card-slot" data-slot="jack" id="slot-jack">
|
|
||||||
<div class="card-bg-letter">J</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">J</span>
|
|
||||||
<span class="suit-icon">♠</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">Jack</div>
|
|
||||||
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
|
|
||||||
<div class="assigned-tech-content"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span class="suit-icon">♠</span>
|
|
||||||
<span class="rank">J</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QUEEN SLOT -->
|
|
||||||
<div class="face-card-slot-wrapper">
|
|
||||||
<div class="face-card-slot" data-slot="queen" id="slot-queen">
|
|
||||||
<div class="card-bg-letter">Q</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">Q</span>
|
|
||||||
<span class="suit-icon">♥</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">Queen</div>
|
|
||||||
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
|
|
||||||
<div class="assigned-tech-content"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span class="suit-icon">♥</span>
|
|
||||||
<span class="rank">Q</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KING SLOT -->
|
|
||||||
<div class="face-card-slot-wrapper">
|
|
||||||
<div class="face-card-slot" data-slot="king" id="slot-king">
|
|
||||||
<div class="card-bg-letter">K</div>
|
|
||||||
<div class="card-slot-header">
|
|
||||||
<span class="rank">K</span>
|
|
||||||
<span class="suit-icon">♦</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-body">
|
|
||||||
<div class="card-title">King</div>
|
|
||||||
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
|
|
||||||
<div class="assigned-tech-content"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-slot-footer">
|
|
||||||
<span class="suit-icon">♦</span>
|
|
||||||
<span class="rank">K</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="submit-assignments-btn" class="btn btn-primary btn-full glow-effect" disabled>
|
|
||||||
Confirm Assignments & Ready Up
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const form = document.getElementById('face-tech-form');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
const chips = form.querySelectorAll('.draggable-tech-chip');
|
|
||||||
const slots = form.querySelectorAll('.face-card-slot');
|
|
||||||
const inputs = {
|
|
||||||
jack: form.querySelector('#input-jack'),
|
|
||||||
queen: form.querySelector('#input-queen'),
|
|
||||||
king: form.querySelector('#input-king')
|
|
||||||
};
|
|
||||||
const submitBtn = form.querySelector('#submit-assignments-btn');
|
|
||||||
let selectedChip = null;
|
|
||||||
|
|
||||||
// Helper: update form validity and submit button state
|
|
||||||
function updateSubmitButton() {
|
|
||||||
const jVal = inputs.jack.value;
|
|
||||||
const qVal = inputs.queen.value;
|
|
||||||
const kVal = inputs.king.value;
|
|
||||||
|
|
||||||
const allAssigned = jVal && qVal && kVal;
|
|
||||||
const uniqueValues = new Set([jVal, qVal, kVal]).size === 3;
|
|
||||||
|
|
||||||
submitBtn.disabled = !(allAssigned && uniqueValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: assign a technique to a slot
|
|
||||||
function assignTech(techText, slotName, chipId) {
|
|
||||||
// Unassign from any other slot first
|
|
||||||
for (const key in inputs) {
|
|
||||||
if (inputs[key].value === techText) {
|
|
||||||
unassignSlot(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unassign current content in this slot
|
|
||||||
unassignSlot(slotName);
|
|
||||||
|
|
||||||
// Set input value
|
|
||||||
inputs[slotName].value = techText;
|
|
||||||
|
|
||||||
// Update slot UI
|
|
||||||
const slotEl = form.querySelector(`#slot-${slotName}`);
|
|
||||||
slotEl.classList.add('has-assignment');
|
|
||||||
|
|
||||||
const contentContainer = slotEl.querySelector('.assigned-tech-content');
|
|
||||||
contentContainer.innerHTML = `
|
|
||||||
<div class="assigned-tech-chip">
|
|
||||||
${techText}
|
|
||||||
<span class="remove-tech-btn" onclick="event.stopPropagation(); window.unassignTech('${slotName}')">×</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Mark chip as assigned in pool
|
|
||||||
const chipEl = form.querySelector(`#${chipId}`);
|
|
||||||
if (chipEl) {
|
|
||||||
chipEl.classList.add('assigned-hidden');
|
|
||||||
chipEl.classList.remove('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedChip) {
|
|
||||||
selectedChip.classList.remove('selected');
|
|
||||||
selectedChip = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSubmitButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: unassign a slot
|
|
||||||
function unassignSlot(slotName) {
|
|
||||||
const input = inputs[slotName];
|
|
||||||
const prevValue = input.value;
|
|
||||||
if (!prevValue) return;
|
|
||||||
|
|
||||||
input.value = '';
|
|
||||||
|
|
||||||
const slotEl = form.querySelector(`#slot-${slotName}`);
|
|
||||||
slotEl.classList.remove('has-assignment');
|
|
||||||
const contentContainer = slotEl.querySelector('.assigned-tech-content');
|
|
||||||
contentContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Return chip to pool
|
|
||||||
const chipsList = form.querySelectorAll('.draggable-tech-chip');
|
|
||||||
chipsList.forEach(chip => {
|
|
||||||
if (chip.getAttribute('data-tech') === prevValue) {
|
|
||||||
chip.classList.remove('assigned-hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSubmitButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.unassignTech = function(slotName) {
|
|
||||||
unassignSlot(slotName);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Setup drag start / end on chips
|
|
||||||
chips.forEach(chip => {
|
|
||||||
chip.addEventListener('dragstart', (e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', chip.getAttribute('data-tech'));
|
|
||||||
e.dataTransfer.setData('chip-id', chip.id);
|
|
||||||
chip.classList.add('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
chip.addEventListener('dragend', () => {
|
|
||||||
chip.classList.remove('dragging');
|
|
||||||
});
|
|
||||||
|
|
||||||
chip.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (chip.classList.contains('assigned-hidden')) return;
|
|
||||||
|
|
||||||
if (selectedChip) {
|
|
||||||
selectedChip.classList.remove('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedChip === chip) {
|
|
||||||
selectedChip = null;
|
|
||||||
} else {
|
|
||||||
selectedChip = chip;
|
|
||||||
chip.classList.add('selected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup drop zone on card slots
|
|
||||||
slots.forEach(slot => {
|
|
||||||
const slotName = slot.getAttribute('data-slot');
|
|
||||||
|
|
||||||
slot.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!slot.classList.contains('has-assignment')) {
|
|
||||||
slot.classList.add('drag-over');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
slot.addEventListener('dragleave', () => {
|
|
||||||
slot.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
slot.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
slot.classList.remove('drag-over');
|
|
||||||
|
|
||||||
const techText = e.dataTransfer.getData('text/plain');
|
|
||||||
const chipId = e.dataTransfer.getData('chip-id');
|
|
||||||
if (techText && chipId) {
|
|
||||||
assignTech(techText, slotName, chipId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
slot.addEventListener('click', () => {
|
|
||||||
if (selectedChip) {
|
|
||||||
const techText = selectedChip.getAttribute('data-tech');
|
|
||||||
const chipId = selectedChip.id;
|
|
||||||
assignTech(techText, slotName, chipId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selection when clicking backdrop card area
|
|
||||||
const cardContainer = document.getElementById('techniques-card');
|
|
||||||
if (cardContainer) {
|
|
||||||
cardContainer.addEventListener('click', (e) => {
|
|
||||||
if (selectedChip && !e.target.closest('.draggable-tech-chip')) {
|
|
||||||
selectedChip.classList.remove('selected');
|
|
||||||
selectedChip = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% for p in game.players %}
|
|
||||||
{% set voted = False %}
|
|
||||||
{% for v in votes if v.voter_player_id == p.id %}
|
|
||||||
{% set voted = True %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
|
|
||||||
<span class="name">{{ p.name }}</span>
|
|
||||||
<span class="status-badge">{% if voted %}Voted{% else %}Voting...{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
@@ -293,26 +293,14 @@ def test_set_role_endpoint():
|
|||||||
def get_session_override():
|
def get_session_override():
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
# Dynamically find the get_session function object used by the route
|
from pirats.database import get_session
|
||||||
get_session_func = None
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
for route in app.routes:
|
|
||||||
if route.path == "/game/{game_id}/player/{player_id}/set-role":
|
|
||||||
for dep in route.dependant.dependencies:
|
|
||||||
if dep.name == "db":
|
|
||||||
get_session_func = dep.call
|
|
||||||
break
|
|
||||||
if get_session_func:
|
|
||||||
break
|
|
||||||
|
|
||||||
assert get_session_func is not None, "Could not find get_session dependency in route"
|
|
||||||
app.dependency_overrides[get_session_func] = get_session_override
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = client.post(f"/game/{game.id}/player/{player.id}/set-role?role=pirat")
|
response = client.post(f"/api/game/{game.id}/player/{player.id}/set-role", data={"role": "pirat"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
# The response should contain the rendered scene setup template
|
assert response.json()["status"] == "ok"
|
||||||
assert "Play Pi-Rat" in response.text
|
|
||||||
# The database should be updated
|
# The database should be updated
|
||||||
session.refresh(player)
|
session.refresh(player)
|
||||||
assert player.role == "pirat"
|
assert player.role == "pirat"
|
||||||
@@ -344,24 +332,13 @@ def test_create_game_endpoint():
|
|||||||
def get_session_override():
|
def get_session_override():
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
# Dynamically find the get_session function object used by the route
|
from pirats.database import get_session
|
||||||
get_session_func = None
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
for route in app.routes:
|
|
||||||
if route.path == "/game/create":
|
|
||||||
for dep in route.dependant.dependencies:
|
|
||||||
if dep.name == "db":
|
|
||||||
get_session_func = dep.call
|
|
||||||
break
|
|
||||||
if get_session_func:
|
|
||||||
break
|
|
||||||
|
|
||||||
assert get_session_func is not None, "Could not find get_session dependency in route"
|
|
||||||
app.dependency_overrides[get_session_func] = get_session_override
|
|
||||||
client = TestClient(app, base_url="http://testserver")
|
client = TestClient(app, base_url="http://testserver")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = client.post("/game/create", data={"crew_name": "The Math Mutineers"})
|
response = client.post("/api/game", data={"crew_name": "The Math Mutineers"})
|
||||||
assert response.status_code in [200, 303] # starlette redirects can be 303
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the database has the game with the crew name
|
# Verify the database has the game with the crew name
|
||||||
from pirats.models import Game
|
from pirats.models import Game
|
||||||
@@ -463,10 +440,9 @@ def test_submit_vote_endpoint():
|
|||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = client.post(f"/game/{game.id}/player/{p1.id}/submit-vote", data={"nominated_id": p2.id})
|
response = client.post(f"/api/game/{game.id}/player/{p1.id}/submit-vote", data={"nominated_id": p2.id})
|
||||||
# TestClient follows redirects by default
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Vote submitted" in response.text
|
assert response.json()["status"] == "ok"
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -505,3 +481,111 @@ def test_single_eligible_deep_player(session):
|
|||||||
success, msg = crud.confirm_scene_setup(session, game.id)
|
success, msg = crud.confirm_scene_setup(session, game.id)
|
||||||
assert success
|
assert success
|
||||||
|
|
||||||
|
def test_player_death_toggle(session):
|
||||||
|
game = crud.create_game(session)
|
||||||
|
player = crud.add_player(session, game.id, "DeadRat")
|
||||||
|
assert not player.is_dead
|
||||||
|
assert not player.completed_personal_3
|
||||||
|
|
||||||
|
# Toggle objective 3
|
||||||
|
crud.toggle_objective(session, game.id, player.id, "personal_3", True)
|
||||||
|
session.refresh(player)
|
||||||
|
assert player.is_dead
|
||||||
|
assert player.completed_personal_3
|
||||||
|
|
||||||
|
# Toggle off
|
||||||
|
crud.toggle_objective(session, game.id, player.id, "personal_3", False)
|
||||||
|
session.refresh(player)
|
||||||
|
assert not player.is_dead
|
||||||
|
assert not player.completed_personal_3
|
||||||
|
|
||||||
|
def test_become_ghost_flow(session):
|
||||||
|
game = crud.create_game(session)
|
||||||
|
player = crud.add_player(session, game.id, "DeadRat")
|
||||||
|
player.is_dead = True
|
||||||
|
session.add(player)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pirats.main import app
|
||||||
|
from pirats.database import get_session
|
||||||
|
|
||||||
|
def get_session_override():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f"/api/game/{game.id}/player/{player.id}/become-ghost")
|
||||||
|
assert response.status_code == 200
|
||||||
|
session.refresh(player)
|
||||||
|
assert player.is_ghost
|
||||||
|
assert not player.is_dead
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_roll_new_character(session):
|
||||||
|
game = crud.create_game(session)
|
||||||
|
p1 = crud.add_player(session, game.id, "P1")
|
||||||
|
p2 = crud.add_player(session, game.id, "P2") # helper player for delegation target
|
||||||
|
|
||||||
|
p1.is_dead = True
|
||||||
|
p1.rank = 3
|
||||||
|
p1.hand_cards = json.dumps(["2C", "3D", "4H"])
|
||||||
|
p1.completed_personal_1 = True
|
||||||
|
p1.completed_personal_2 = True
|
||||||
|
p1.completed_personal_3 = True
|
||||||
|
|
||||||
|
session.add_all([p1, p2])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
crud.roll_new_character(
|
||||||
|
session,
|
||||||
|
game.id,
|
||||||
|
p1.id,
|
||||||
|
avatar_look="Shiny eyes",
|
||||||
|
avatar_smell="Spiced Rum",
|
||||||
|
first_words="No fear!",
|
||||||
|
good_at_math="Yes"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.refresh(p1)
|
||||||
|
session.refresh(game)
|
||||||
|
|
||||||
|
# Verify properties
|
||||||
|
assert not p1.is_dead
|
||||||
|
assert not p1.is_ghost
|
||||||
|
assert not p1.completed_personal_1
|
||||||
|
assert not p1.completed_personal_2
|
||||||
|
assert not p1.completed_personal_3
|
||||||
|
assert p1.avatar_look == "Shiny eyes"
|
||||||
|
assert p1.avatar_smell == "Spiced Rum"
|
||||||
|
assert p1.first_words == "No fear!"
|
||||||
|
assert p1.good_at_math == "Yes"
|
||||||
|
|
||||||
|
# Verify rank randomized between 1-3
|
||||||
|
assert p1.rank in [1, 2, 3]
|
||||||
|
|
||||||
|
# Verify name is "Recruit Spiced Rum"
|
||||||
|
assert p1.name == "Recruit Spiced Rum"
|
||||||
|
|
||||||
|
# Verify techniques auto-assigned
|
||||||
|
assert p1.tech_jack != ""
|
||||||
|
assert p1.tech_queen != ""
|
||||||
|
assert p1.tech_king != ""
|
||||||
|
|
||||||
|
# Verify delegated targets exist (p2 is the only other player)
|
||||||
|
assert p1.other_like_from_player_id == p2.id
|
||||||
|
assert p1.other_hate_from_player_id == p2.id
|
||||||
|
|
||||||
|
# Verify hand was refreshed up to new max hand size
|
||||||
|
hand = json.loads(p1.hand_cards)
|
||||||
|
assert len(hand) > 0
|
||||||
|
# Old cards returned to deck
|
||||||
|
deck = json.loads(game.deck_cards)
|
||||||
|
assert "2C" in deck or "2C" in hand
|
||||||
|
assert "3D" in deck or "3D" in hand
|
||||||
|
assert "4H" in deck or "4H" in hand
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user