Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222f32d98f | ||
|
|
454ded8b26 | ||
|
|
9e85ba1fa4 | ||
|
|
2fb8d559a6 | ||
|
|
b0a103d8ce | ||
|
|
cb056e010f | ||
|
|
de15cb4206 | ||
|
|
f796cac6be | ||
|
|
31580df919 | ||
|
|
9a17209db2 | ||
|
|
04656790d2 | ||
|
|
25a0bd3431 | ||
|
|
6abbd4652a | ||
|
|
8369d10bab | ||
|
|
bdfe0d1594 | ||
|
|
034f2ead1c | ||
|
|
06c3891045 | ||
|
|
f09cbe6939 | ||
|
|
cd9e1e7242 | ||
|
|
966bad98d3 | ||
|
|
2b89fb7ede | ||
|
|
0fc88323ea | ||
|
|
96eba8270c | ||
|
|
a36c05994c | ||
|
|
ef78a85b9c | ||
|
|
f368da9e73 | ||
|
|
4f89ed3082 | ||
|
|
0d8152914a | ||
|
|
12809623c1 | ||
|
|
a29bb63a1d | ||
|
|
9e95194627 | ||
|
|
451fb206a6 | ||
|
|
e3c49effdb | ||
|
|
5c40249a18 | ||
|
|
b645f55f57 | ||
|
|
838fbe1b50 | ||
|
|
94d259e92a | ||
|
|
56db5bc998 | ||
|
|
ebac1ad6cc | ||
|
|
abca1922f2 | ||
|
|
e0dcfe6abe | ||
|
|
132f92d2d9 | ||
|
|
70a149ab82 | ||
|
|
26a0e532f6 | ||
|
|
e521d3086f | ||
|
|
9c4da51cfb | ||
|
|
24211dca14 | ||
|
|
87b66cd65d | ||
|
|
0dadc82f84 | ||
|
|
5527981fff | ||
|
|
9f105ada5e | ||
|
|
cac9fae142 | ||
|
|
b832723d6b | ||
|
|
0c3b289ba0 | ||
|
|
f4b36a745e | ||
|
|
3b53c9cb5f | ||
|
|
3bdb720e4a | ||
|
|
f290eeeb8a | ||
|
|
4b3f6590cc | ||
|
|
069c0b93ef | ||
|
|
33a1848096 | ||
|
|
55df982a0b | ||
|
|
eb7dfaf6f5 | ||
|
|
aa145592c5 | ||
|
|
37fa5fc3c8 | ||
|
|
db10ebe220 | ||
|
|
a5478dce2b | ||
|
|
29b6153777 | ||
|
|
d3e83bac66 | ||
|
|
40ae93f68b | ||
|
|
1e978dff58 | ||
|
|
3c256ba0b2 | ||
|
|
70d59a091a | ||
|
|
9569972cd6 | ||
|
|
5bd390b4ee | ||
|
|
5f8819bb46 | ||
|
|
b8cf136ff7 | ||
|
|
5188d86d61 | ||
|
|
6a1498813f | ||
|
|
e4f7c03005 | ||
|
|
38098a02ea | ||
|
|
fa09ef25e2 | ||
|
|
ba8afd144e | ||
|
|
8ef1873410 | ||
|
|
289044e26f | ||
|
|
47ea6d8620 | ||
|
|
21b5fedfc9 | ||
|
|
912ce5b942 | ||
|
|
4ead7e60b1 | ||
|
|
e64ffdc4cb | ||
|
|
1d601febcf | ||
|
|
3edda1d707 | ||
|
|
e56e133a69 | ||
|
|
0f871026eb | ||
|
|
782a138fd8 | ||
|
|
58d07a02fd | ||
|
|
01bb73f6a2 | ||
|
|
602147e961 | ||
|
|
9e6bb8b148 | ||
|
|
305a0b0553 | ||
|
|
023ff9fb1b | ||
|
|
56353a7756 | ||
|
|
86142cba6c | ||
|
|
0517cd638c | ||
|
|
b8303a7e28 | ||
|
|
d259c0c6a6 | ||
|
|
8b9ab2cd29 | ||
|
|
5d832c9601 | ||
|
|
968cc09c98 | ||
|
|
2bddab001a | ||
|
|
fc058effd5 | ||
|
|
3f99a77446 | ||
|
|
abe25e0ceb | ||
|
|
5a20ed23f4 | ||
|
|
0142508eb5 | ||
|
|
5863418ae9 | ||
|
|
a96c6caa49 | ||
|
|
22e446ff28 | ||
|
|
10c84a8478 | ||
|
|
9eba64621a |
@@ -31,3 +31,4 @@ PANEL_BASE_URL=http://localhost:3000
|
|||||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||||
VPS_USER=deploy
|
VPS_USER=deploy
|
||||||
VPS_HOST=your-vps-ip
|
VPS_HOST=your-vps-ip
|
||||||
|
SESSION_SECRET=change-me-to-a-random-string
|
||||||
132
.gitea/workflows/ci-deploy.yml
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
name: CI / Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: aurora_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Create Config File
|
||||||
|
run: |
|
||||||
|
mkdir -p shared/config
|
||||||
|
cat <<EOF > shared/config/config.json
|
||||||
|
{
|
||||||
|
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||||
|
"economy": {
|
||||||
|
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||||
|
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||||
|
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||||
|
},
|
||||||
|
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||||
|
"commands": {},
|
||||||
|
"lootdrop": {
|
||||||
|
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||||
|
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||||
|
},
|
||||||
|
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||||
|
"moderation": {
|
||||||
|
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||||
|
"cases": { "dmOnWarn": false }
|
||||||
|
},
|
||||||
|
"trivia": {
|
||||||
|
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||||
|
"categories": [], "difficulty": "random"
|
||||||
|
},
|
||||||
|
"system": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bunx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Build Panel
|
||||||
|
run: bun run panel:build
|
||||||
|
|
||||||
|
- name: Setup Test Database
|
||||||
|
run: bun run db:push:local
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||||
|
DISCORD_BOT_TOKEN: test_token
|
||||||
|
DISCORD_CLIENT_ID: "123"
|
||||||
|
DISCORD_GUILD_ID: "123"
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
cat <<EOF > .env.test
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||||
|
DISCORD_BOT_TOKEN="test_token"
|
||||||
|
DISCORD_CLIENT_ID="123456789"
|
||||||
|
DISCORD_GUILD_ID="123456789"
|
||||||
|
DISCORD_CLIENT_SECRET="test-client-secret"
|
||||||
|
SESSION_SECRET="test-session-secret"
|
||||||
|
ADMIN_TOKEN="admin_token_123"
|
||||||
|
LOG_LEVEL="error"
|
||||||
|
EOF
|
||||||
|
bash shared/scripts/test-isolated.sh --integration
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: test
|
||||||
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Deploy on VPS
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||||
|
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && bash shared/scripts/deploy.sh"
|
||||||
|
|
||||||
|
- name: Post-deploy Health Check
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||||
|
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && curl -fsS http://127.0.0.1:3000/api/health >/dev/null"
|
||||||
10
.github/workflows/deploy.yml
vendored
@@ -38,9 +38,9 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
run: |
|
||||||
with:
|
curl -fsSL https://bun.sh/install | bash
|
||||||
bun-version: latest
|
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: |
|
run: |
|
||||||
# Create .env.test for test-sequential.sh / bun test
|
# Create .env.test for the isolated test runner / bun test
|
||||||
cat <<EOF > .env.test
|
cat <<EOF > .env.test
|
||||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||||
DISCORD_BOT_TOKEN="test_token"
|
DISCORD_BOT_TOKEN="test_token"
|
||||||
@@ -95,6 +95,6 @@ jobs:
|
|||||||
ADMIN_TOKEN="admin_token_123"
|
ADMIN_TOKEN="admin_token_123"
|
||||||
LOG_LEVEL="error"
|
LOG_LEVEL="error"
|
||||||
EOF
|
EOF
|
||||||
bash shared/scripts/test-sequential.sh --integration
|
bash shared/scripts/test-isolated.sh --integration
|
||||||
env:
|
env:
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -50,3 +50,5 @@ scratchpad/
|
|||||||
bot/assets/graphics/items
|
bot/assets/graphics/items
|
||||||
tickets/
|
tickets/
|
||||||
.citrine.local
|
.citrine.local
|
||||||
|
.worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
312
AGENTS.md
@@ -1,257 +1,135 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
# AGENTS.md
|
||||||
|
|
||||||
## Project Overview
|
This file documents the current implementation shape of the Aurora repository.
|
||||||
|
|
||||||
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
## Commands
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# App
|
||||||
bun --watch bot/index.ts # Run bot + API server with hot reload
|
bun run dev # bot + API in one Bun process with watch mode
|
||||||
|
docker compose up # app + db
|
||||||
|
docker compose up app # app only
|
||||||
|
docker compose up db # database only
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
bun test # Run all tests
|
bun test # Bun's native runner
|
||||||
bun test path/to/file.test.ts # Run single test file
|
bun run test # repo test wrapper script
|
||||||
bun test --watch # Watch mode
|
bun run test:ci # include CI/integration path
|
||||||
bun test shared/modules/economy # Run tests in directory
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
bun run generate # Generate Drizzle migrations (Docker)
|
bun run db:push # drizzle-kit push via Docker
|
||||||
bun run migrate # Run migrations (Docker)
|
bun run db:push:local # drizzle-kit push locally
|
||||||
bun run db:push # Push schema changes (Docker)
|
bun run db:generate # drizzle-kit generate via Docker
|
||||||
bun run db:push:local # Push schema changes (local)
|
bun run db:migrate # drizzle-kit migrate via Docker
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # local Drizzle Studio on :4983
|
||||||
|
|
||||||
# Docker (recommended for local dev)
|
# Panel
|
||||||
docker compose up # Start bot, API, and database
|
bun run panel:dev # Vite dev server on :5173
|
||||||
docker compose up app # Start just the app (bot + API)
|
bun run panel:build # build panel/dist
|
||||||
docker compose up db # Start just the database
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Architecture
|
||||||
|
|
||||||
```
|
Aurora is a single-process Bun application:
|
||||||
bot/ # Discord bot
|
|
||||||
├── commands/ # Slash commands by category
|
|
||||||
├── events/ # Discord event handlers
|
|
||||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
|
||||||
├── modules/ # Feature modules (views, interactions)
|
|
||||||
└── graphics/ # Canvas image generation
|
|
||||||
|
|
||||||
shared/ # Shared between bot and web
|
- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord.
|
||||||
├── db/ # Database schema and migrations
|
- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets.
|
||||||
├── lib/ # Utils, config, errors, types
|
- `shared/modules/*` contains the business logic used by both the bot and the API.
|
||||||
└── modules/ # Domain services (economy, user, etc.)
|
- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration.
|
||||||
|
|
||||||
web/ # API server
|
Current high-level layout:
|
||||||
└── src/routes/ # API route handlers
|
|
||||||
|
```text
|
||||||
|
bot/ Discord commands, events, views, interactions
|
||||||
|
api/ Bun HTTP + WebSocket server
|
||||||
|
panel/ React dashboard
|
||||||
|
shared/db/ Drizzle client and schema
|
||||||
|
shared/lib/ config, env, errors, logger, events, constants
|
||||||
|
shared/modules/ domain services
|
||||||
|
shared/games/ game plugins shared by API and panel
|
||||||
```
|
```
|
||||||
|
|
||||||
## Import Conventions
|
## Import conventions
|
||||||
|
|
||||||
Use path aliases defined in tsconfig.json:
|
Use path aliases from the repo `tsconfig.json`:
|
||||||
|
|
||||||
```typescript
|
- `@/*` -> `bot/*`
|
||||||
// External packages first
|
- `@commands/*` -> `bot/commands/*`
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
- `@db/*` -> `shared/db/*`
|
||||||
import { eq } from "drizzle-orm";
|
- `@lib/*` -> `bot/lib/*`
|
||||||
|
- `@modules/*` -> `bot/modules/*`
|
||||||
|
- `@shared/*` -> `shared/*`
|
||||||
|
|
||||||
// Path aliases second
|
Import order in the repo is generally:
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
|
||||||
import { UserError } from "@shared/lib/errors";
|
|
||||||
import { users } from "@db/schema";
|
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
|
||||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
|
||||||
|
|
||||||
// Relative imports last
|
1. external packages
|
||||||
import { localHelper } from "./helper";
|
2. aliases
|
||||||
```
|
3. relative imports
|
||||||
|
|
||||||
**Available Aliases:**
|
## File patterns
|
||||||
|
|
||||||
- `@/*` - bot/
|
- `*.service.ts`: domain/business logic, usually in `shared/modules/*`
|
||||||
- `@shared/*` - shared/
|
- `*.view.ts`: Discord message/view construction
|
||||||
- `@db/*` - shared/db/
|
- `*.interaction.ts`: component interaction handlers
|
||||||
- `@lib/*` - bot/lib/
|
- `*.types.ts`: local types and custom ID helpers
|
||||||
- `@modules/*` - bot/modules/
|
- `*.handler.ts`: bot-side orchestration around services/views
|
||||||
- `@commands/*` - bot/commands/
|
- `*.test.ts`: colocated tests
|
||||||
|
|
||||||
## Naming Conventions
|
## Runtime config
|
||||||
|
|
||||||
| Element | Convention | Example |
|
- Global game settings live in `game_settings` and are loaded into `shared/lib/config.ts`.
|
||||||
| ---------------- | ----------------------- | ---------------------------------------- |
|
- Guild-specific settings live in `guild_settings`; `getGuildConfig()` adds a 60-second cache on top of DB reads.
|
||||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
- Most numeric DB values exposed through runtime config are converted to `bigint` in `shared/lib/config.ts`.
|
||||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
|
||||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
|
||||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
|
||||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
|
||||||
| Services | camelCase singleton | `economyService`, `userService` |
|
|
||||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
|
||||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
|
||||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
|
||||||
|
|
||||||
## Code Patterns
|
## Interaction routing
|
||||||
|
|
||||||
### Command Definition
|
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
|
||||||
|
|
||||||
```typescript
|
Current route table:
|
||||||
export const commandName = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("commandname")
|
|
||||||
.setDescription("Description"),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
await interaction.deferReply();
|
|
||||||
// Implementation
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Pattern (Singleton Object)
|
- `trade_` and `amount` -> `bot/modules/trade/trade.interaction.ts`
|
||||||
|
- `shop_buy_` -> `bot/modules/economy/shop.interaction.ts`
|
||||||
|
- `lootdrop_` -> `bot/modules/economy/lootdrop.interaction.ts`
|
||||||
|
- `trivia_` -> `bot/modules/trivia/trivia.interaction.ts`
|
||||||
|
- `createitem_` -> `bot/modules/admin/item_wizard.ts`
|
||||||
|
- `enrollment` -> `bot/modules/user/enrollment.interaction.ts`
|
||||||
|
- `feedback_` -> `bot/modules/feedback/feedback.interaction.ts`
|
||||||
|
|
||||||
```typescript
|
Some features still use local collectors instead of the global route table, notably inventory.
|
||||||
export const serviceName = {
|
|
||||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
|
||||||
return await withTransaction(async (tx) => {
|
|
||||||
// Database operations
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module File Organization
|
## Commands and access control
|
||||||
|
|
||||||
- `*.view.ts` - Creates Discord embeds/components
|
- Slash command execution is centralized in `bot/lib/handlers/CommandHandler.ts`.
|
||||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
- `withCommandErrorHandling()` is the normal command wrapper for defer/reply/error behavior.
|
||||||
- `*.types.ts` - Module-specific TypeScript types
|
- Beta commands rely on `featureFlagsService.hasAccess()`.
|
||||||
- `*.service.ts` - Business logic (in shared/modules/)
|
- `ADMIN_USER_IDS` controls admin panel access, not Discord permissions inside command code.
|
||||||
- `*.test.ts` - Test files (co-located with source)
|
|
||||||
|
|
||||||
## Error Handling
|
## API and panel
|
||||||
|
|
||||||
### Custom Error Classes
|
- API routes are prefix-matched in `api/src/routes/index.ts`.
|
||||||
|
- `/auth/*` and `/api/health` are public.
|
||||||
|
- Players may access `/api/stats`, `/api/health`, `/api/me`, and `/api/me/inventory`.
|
||||||
|
- Remaining `/api/*` routes are admin-only.
|
||||||
|
- The panel dev server proxies back to the Bun server; the integrated server serves `panel/dist` when built.
|
||||||
|
|
||||||
```typescript
|
## Database notes
|
||||||
import { UserError, SystemError } from "@shared/lib/errors";
|
|
||||||
|
|
||||||
// User-facing errors (shown to user)
|
- Docker Compose uses PostgreSQL 17.
|
||||||
throw new UserError("You don't have enough coins!");
|
- Discord IDs and currency/xp values are stored as `bigint`.
|
||||||
|
- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work.
|
||||||
// System errors (logged, generic message shown)
|
|
||||||
throw new SystemError("Database connection failed");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recommended: `withCommandErrorHandling`
|
|
||||||
|
|
||||||
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
|
||||||
error handling across all commands. It handles `deferReply`, `UserError` display,
|
|
||||||
and unexpected error logging automatically.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
|
||||||
|
|
||||||
export const myCommand = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("mycommand")
|
|
||||||
.setDescription("Does something"),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
await withCommandErrorHandling(
|
|
||||||
interaction,
|
|
||||||
async () => {
|
|
||||||
const result = await service.method();
|
|
||||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
|
||||||
},
|
|
||||||
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- `ephemeral` — whether `deferReply` should be ephemeral
|
|
||||||
- `successMessage` — a simple string to send on success
|
|
||||||
- `onSuccess` — a callback invoked with the operation result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Patterns
|
|
||||||
|
|
||||||
### Transaction Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { withTransaction } from "@/lib/db";
|
|
||||||
|
|
||||||
return await withTransaction(async (tx) => {
|
|
||||||
const user = await tx.query.users.findFirst({
|
|
||||||
where: eq(users.id, discordId),
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(users)
|
|
||||||
.set({ coins: newBalance })
|
|
||||||
.where(eq(users.id, discordId));
|
|
||||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}, existingTx); // Pass existing tx if in nested transaction
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema Notes
|
|
||||||
|
|
||||||
- Use `bigint` mode for Discord IDs and currency amounts
|
|
||||||
- Relations defined separately from table definitions
|
|
||||||
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Test File Structure
|
- Tests use `bun:test`.
|
||||||
|
- Mock modules before importing the unit under test.
|
||||||
|
- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database.
|
||||||
|
|
||||||
```typescript
|
## Key entrypoints
|
||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
||||||
|
|
||||||
// Mock modules BEFORE imports
|
- `bot/index.ts`
|
||||||
mock.module("@shared/db/DrizzleClient", () => ({
|
- `bot/lib/BotClient.ts`
|
||||||
DrizzleClient: { query: mockQuery },
|
- `api/src/server.ts`
|
||||||
}));
|
- `api/src/routes/index.ts`
|
||||||
|
- `shared/lib/config.ts`
|
||||||
describe("serviceName", () => {
|
- `shared/db/DrizzleClient.ts`
|
||||||
beforeEach(() => {
|
- `shared/db/schema/index.ts`
|
||||||
mockFn.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle expected case", async () => {
|
|
||||||
// Arrange
|
|
||||||
mockFn.mockResolvedValue(testData);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.method(input);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime:** Bun 1.0+
|
|
||||||
- **Bot:** Discord.js 14.x
|
|
||||||
- **Web:** Bun HTTP Server (REST API)
|
|
||||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
|
||||||
- **UI:** Discord embeds and components
|
|
||||||
- **Validation:** Zod
|
|
||||||
- **Testing:** Bun Test
|
|
||||||
- **Container:** Docker
|
|
||||||
|
|
||||||
## Key Files Reference
|
|
||||||
|
|
||||||
| Purpose | File |
|
|
||||||
| ------------- | ---------------------- |
|
|
||||||
| Bot entry | `bot/index.ts` |
|
|
||||||
| DB schema | `shared/db/schema.ts` |
|
|
||||||
| Error classes | `shared/lib/errors.ts` |
|
|
||||||
| Config loader | `shared/lib/config.ts` |
|
|
||||||
| Environment | `shared/lib/env.ts` |
|
|
||||||
| Embed helpers | `bot/lib/embeds.ts` |
|
|
||||||
| Command utils | `shared/lib/utils.ts` |
|
|
||||||
| Error handler | `bot/lib/commandUtils.ts` |
|
|
||||||
|
|||||||
187
CLAUDE.md
@@ -1,187 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
bun --watch bot/index.ts # Run bot + API with hot reload
|
|
||||||
docker compose up # Start all services (bot, API, database)
|
|
||||||
docker compose up app # Start just the app (bot + API)
|
|
||||||
docker compose up db # Start just the database
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
bun test # Run all tests
|
|
||||||
bun test path/to/file.test.ts # Run a single test file
|
|
||||||
bun test shared/modules/economy # Run tests in a directory
|
|
||||||
bun test --watch # Watch mode
|
|
||||||
|
|
||||||
# Database
|
|
||||||
bun run db:push:local # Push schema changes (local)
|
|
||||||
bun run db:studio # Open Drizzle Studio (localhost:4983)
|
|
||||||
bun run generate # Generate Drizzle migrations (Docker)
|
|
||||||
bun run migrate # Apply migrations (Docker)
|
|
||||||
|
|
||||||
# Admin Panel
|
|
||||||
bun run panel:dev # Start Vite dev server for dashboard
|
|
||||||
bun run panel:build # Build React dashboard for production
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
|
|
||||||
|
|
||||||
```
|
|
||||||
bot/ # Discord bot
|
|
||||||
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
|
|
||||||
├── events/ # Discord event handlers
|
|
||||||
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
|
|
||||||
├── modules/ # Feature modules (views, interactions per domain)
|
|
||||||
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
|
|
||||||
|
|
||||||
shared/ # Shared between bot and API
|
|
||||||
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
|
|
||||||
├── lib/ # env, config, errors, logger, types, utils
|
|
||||||
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
|
|
||||||
|
|
||||||
api/ # REST API (Bun HTTP server)
|
|
||||||
└── src/routes/ # Route handlers for each domain
|
|
||||||
|
|
||||||
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key architectural details:**
|
|
||||||
- Bot and API both import from `shared/` — do not duplicate logic.
|
|
||||||
- Services in `shared/modules/` are singleton objects, not classes.
|
|
||||||
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
|
|
||||||
- Feature modules follow a strict file suffix convention (see below).
|
|
||||||
|
|
||||||
## Import Conventions
|
|
||||||
|
|
||||||
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { SlashCommandBuilder } from "discord.js"; // external
|
|
||||||
import { economyService } from "@shared/modules/economy/economy.service"; // alias
|
|
||||||
import { users } from "@db/schema"; // alias
|
|
||||||
import { createErrorEmbed } from "@lib/embeds"; // alias
|
|
||||||
import { localHelper } from "./helper"; // relative
|
|
||||||
```
|
|
||||||
|
|
||||||
**Aliases:**
|
|
||||||
- `@/*` → `bot/`
|
|
||||||
- `@shared/*` → `shared/`
|
|
||||||
- `@db/*` → `shared/db/`
|
|
||||||
- `@lib/*` → `bot/lib/`
|
|
||||||
- `@modules/*` → `bot/modules/`
|
|
||||||
- `@commands/*` → `bot/commands/`
|
|
||||||
|
|
||||||
## Code Patterns
|
|
||||||
|
|
||||||
### Module File Suffixes
|
|
||||||
|
|
||||||
- `*.view.ts` — Creates Discord embeds/components
|
|
||||||
- `*.interaction.ts` — Handles button/select/modal interactions
|
|
||||||
- `*.service.ts` — Business logic (lives in `shared/modules/`)
|
|
||||||
- `*.types.ts` — Module-specific TypeScript types
|
|
||||||
- `*.test.ts` — Tests (co-located with source)
|
|
||||||
|
|
||||||
### Command Definition
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const commandName = createCommand({
|
|
||||||
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
await withCommandErrorHandling(interaction, async () => {
|
|
||||||
const result = await service.method();
|
|
||||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
|
||||||
}, { ephemeral: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
|
|
||||||
|
|
||||||
### Service Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const serviceName = {
|
|
||||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
|
||||||
return await withTransaction(async (tx) => {
|
|
||||||
// database operations
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { UserError, SystemError } from "@shared/lib/errors";
|
|
||||||
|
|
||||||
throw new UserError("You don't have enough coins!"); // shown to user
|
|
||||||
throw new SystemError("DB connection failed"); // logged, generic message shown
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Transactions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { withTransaction } from "@/lib/db";
|
|
||||||
|
|
||||||
return await withTransaction(async (tx) => {
|
|
||||||
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
|
|
||||||
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
|
|
||||||
return user;
|
|
||||||
}, existingTx); // pass existing tx for nested transactions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
Mock modules **before** imports. Use `bun:test`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
||||||
|
|
||||||
mock.module("@shared/db/DrizzleClient", () => ({
|
|
||||||
DrizzleClient: { query: mockQuery },
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("serviceName", () => {
|
|
||||||
beforeEach(() => mockFn.mockClear());
|
|
||||||
it("should handle expected case", async () => {
|
|
||||||
mockFn.mockResolvedValue(testData);
|
|
||||||
const result = await service.method(input);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
| Element | Convention | Example |
|
|
||||||
| ---------------- | ---------------------- | -------------------------------- |
|
|
||||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
|
||||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
|
||||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
|
||||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
|
||||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
|
||||||
| Services | camelCase singleton | `economyService`, `userService` |
|
|
||||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
|
||||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
|
||||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
|
||||||
| API routes | kebab-case | `/api/guild-settings` |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| Purpose | File |
|
|
||||||
| ----------------- | -------------------------- |
|
|
||||||
| Bot entry point | `bot/index.ts` |
|
|
||||||
| Discord client | `bot/lib/BotClient.ts` |
|
|
||||||
| DB schema index | `shared/db/schema.ts` |
|
|
||||||
| Error classes | `shared/lib/errors.ts` |
|
|
||||||
| Environment vars | `shared/lib/env.ts` |
|
|
||||||
| Config loader | `shared/lib/config.ts` |
|
|
||||||
| Embed helpers | `bot/lib/embeds.ts` |
|
|
||||||
| Command utils | `bot/lib/commandUtils.ts` |
|
|
||||||
| API server | `api/src/server.ts` |
|
|
||||||
270
README.md
@@ -1,159 +1,181 @@
|
|||||||
# Aurora
|
# Aurora
|
||||||
|
|
||||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
Aurora is a Discord RPG bot, admin/player panel, and REST/WebSocket API that run as one Bun application. The Discord bot and HTTP server share the same database client, config, services, and domain events.
|
||||||
|
|
||||||

|
## What exists today
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
- Discord slash commands for economy, inventory, quests, moderation, feedback, user profiles, and admin tooling.
|
||||||
|
- A Bun HTTP API under `/api/*`, Discord OAuth under `/auth/*`, and a WebSocket endpoint at `/ws`.
|
||||||
|
- A React panel for both admins and enrolled players.
|
||||||
|
- Shared domain services in `shared/modules/*` and reusable game plugins in `shared/games/*`.
|
||||||
|
- Built-in real-time games: chess and blackjack.
|
||||||
|
|
||||||
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
## Architecture
|
||||||
|
|
||||||
## ✨ Features
|
```text
|
||||||
|
bot/ Discord bot entrypoint, commands, events, Discord-facing views/interactions
|
||||||
|
api/ Bun HTTP server, route modules, WebSocket/game room server
|
||||||
|
panel/ React 19 + Vite + Tailwind v4 dashboard
|
||||||
|
shared/ Shared DB schema, services, config, events, utilities, game plugins
|
||||||
|
docs/ Product and design notes
|
||||||
|
```
|
||||||
|
|
||||||
### Discord Bot
|
Important points:
|
||||||
* **Class System**: Users can join different classes.
|
|
||||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
|
||||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
|
||||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
|
||||||
* **Quests**: Quest system with requirements and rewards.
|
|
||||||
* **Trading**: Secure trading system between users.
|
|
||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
|
||||||
|
|
||||||
### REST API
|
- `bot/index.ts` initializes DB-backed config, wires domain events, starts the API server, then logs into Discord.
|
||||||
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
|
- The API server also serves built panel assets from `panel/dist` when they exist.
|
||||||
* **Configuration Management**: Update bot settings via API.
|
- Bot commands, API routes, and the panel all rely on the same service layer in `shared/modules/*`.
|
||||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
- Runtime game config is loaded from the `game_settings` table into `shared/lib/config.ts`.
|
||||||
* **WebSocket Support**: Real-time event streaming for live updates.
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Getting started
|
||||||
|
|
||||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
|
||||||
|
|
||||||
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
|
|
||||||
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
|
||||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
|
||||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
|
||||||
* **API Framework**: Bun HTTP Server (REST API)
|
|
||||||
* **UI**: Discord embeds and components
|
|
||||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
|
||||||
* **Containerization**: [Docker](https://www.docker.com/)
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
* [Bun](https://bun.sh/) (latest version)
|
- Bun
|
||||||
* [Docker](https://www.docker.com/) & Docker Compose
|
- Docker and Docker Compose
|
||||||
|
- A Discord application with bot token, client ID, and client secret
|
||||||
|
|
||||||
### Installation
|
### Setup
|
||||||
|
|
||||||
1. **Clone the repository**
|
1. Install dependencies.
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd aurora
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
```bash
|
||||||
```bash
|
bun install
|
||||||
bun install
|
```
|
||||||
```
|
|
||||||
|
|
||||||
3. **Environment Setup**
|
2. Create your environment file.
|
||||||
Copy the example environment file and configure it:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
|
||||||
|
|
||||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
4. **Start the Database**
|
3. Start PostgreSQL.
|
||||||
Run the database service using Docker Compose:
|
|
||||||
```bash
|
|
||||||
docker compose up -d db
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Run Migrations**
|
```bash
|
||||||
```bash
|
docker compose up -d db
|
||||||
bun run migrate
|
```
|
||||||
```
|
|
||||||
OR
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the Bot & API
|
4. Initialize the schema.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:push:local
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer running schema changes through Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the bot and API.
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
* Bot: Online in Discord
|
|
||||||
* API: http://localhost:3000
|
|
||||||
|
|
||||||
**Production Mode**:
|
The Bun server listens on `http://localhost:3000`.
|
||||||
Build and run with Docker (recommended):
|
|
||||||
|
### Panel development
|
||||||
|
|
||||||
|
The Bun server can serve a built panel, but day-to-day panel work is done with Vite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d app
|
bun run panel:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
|
||||||
|
|
||||||
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
To build the panel for the integrated Bun server:
|
||||||
|
|
||||||
To access them from your local machine, use the included SSH tunnel script.
|
```bash
|
||||||
|
bun run panel:build
|
||||||
1. Add your VPS details to your local `.env` file:
|
|
||||||
```env
|
|
||||||
VPS_USER=root
|
|
||||||
VPS_HOST=123.45.67.89
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Run the remote connection script:
|
|
||||||
```bash
|
|
||||||
bun run remote
|
|
||||||
```
|
|
||||||
|
|
||||||
This will establish secure tunnels for:
|
|
||||||
* **API**: http://localhost:3000
|
|
||||||
* **Drizzle Studio**: http://localhost:4983
|
|
||||||
|
|
||||||
## 📜 Scripts
|
|
||||||
|
|
||||||
* `bun run dev`: Start the bot and API server in watch mode.
|
|
||||||
* `bun run remote`: Open SSH tunnel to production services.
|
|
||||||
* `bun run generate`: Generate Drizzle migrations.
|
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
|
||||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
|
||||||
* `bun test`: Run tests.
|
|
||||||
|
|
||||||
## 📂 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
├── bot # Discord Bot logic & entry point
|
|
||||||
├── web # REST API Server
|
|
||||||
├── shared # Shared code (Database, Config, Types)
|
|
||||||
├── drizzle # Drizzle migration files
|
|
||||||
├── scripts # Utility scripts
|
|
||||||
├── docker-compose.yml
|
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Useful scripts
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
```bash
|
||||||
|
# App
|
||||||
|
bun run dev
|
||||||
|
docker compose up
|
||||||
|
docker compose up app
|
||||||
|
docker compose up db
|
||||||
|
|
||||||
## 📄 License
|
# Database
|
||||||
|
bun run db:push
|
||||||
|
bun run db:push:local
|
||||||
|
bun run db:generate
|
||||||
|
bun run db:migrate
|
||||||
|
bun run db:studio
|
||||||
|
bun run db:backup
|
||||||
|
bun run db:restore
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
# Panel
|
||||||
|
bun run panel:dev
|
||||||
|
bun run panel:build
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
bun test
|
||||||
|
bun run test
|
||||||
|
bun run test:ci
|
||||||
|
|
||||||
|
# Ops
|
||||||
|
bun run remote
|
||||||
|
bun run deploy
|
||||||
|
bun run deploy:remote
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment notes
|
||||||
|
|
||||||
|
The main variables you need in `.env` are:
|
||||||
|
|
||||||
|
- `DISCORD_BOT_TOKEN`
|
||||||
|
- `DISCORD_CLIENT_ID`
|
||||||
|
- `DISCORD_CLIENT_SECRET`
|
||||||
|
- `DISCORD_GUILD_ID`
|
||||||
|
- `ADMIN_USER_IDS`
|
||||||
|
- `SESSION_SECRET`
|
||||||
|
- `DB_USER`
|
||||||
|
- `DB_PASSWORD`
|
||||||
|
- `DB_NAME`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `PANEL_BASE_URL`
|
||||||
|
|
||||||
|
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
|
||||||
|
|
||||||
|
## API and panel summary
|
||||||
|
|
||||||
|
- Public routes: `/auth/*`, `/api/health`
|
||||||
|
- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory`
|
||||||
|
- Admin-only API routes: the rest of `/api/*`
|
||||||
|
- WebSocket: `/ws` with cookie-based auth
|
||||||
|
- Static assets: `/assets/*`
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
bot/
|
||||||
|
commands/
|
||||||
|
events/
|
||||||
|
lib/
|
||||||
|
modules/
|
||||||
|
|
||||||
|
api/
|
||||||
|
src/
|
||||||
|
routes/
|
||||||
|
games/
|
||||||
|
|
||||||
|
panel/
|
||||||
|
src/
|
||||||
|
|
||||||
|
shared/
|
||||||
|
db/
|
||||||
|
games/
|
||||||
|
lib/
|
||||||
|
modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [AGENTS.md](AGENTS.md): repo-wide implementation guidance
|
||||||
|
- [api/README.md](api/README.md): API surface and auth model
|
||||||
|
- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language
|
||||||
|
|||||||
132
api/README.md
@@ -1,30 +1,130 @@
|
|||||||
# Aurora Web API
|
# Aurora API
|
||||||
|
|
||||||
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
|
Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files.
|
||||||
|
|
||||||
## API Endpoints
|
## Runtime model
|
||||||
|
|
||||||
- `GET /api/stats` - Real-time bot statistics
|
- Entry point: `api/src/server.ts`
|
||||||
- `GET /api/settings` - Bot configuration
|
- Route dispatcher: `api/src/routes/index.ts`
|
||||||
- `GET /api/users` - User data
|
- Auth: Discord OAuth with signed session cookies
|
||||||
- `GET /api/items` - Item catalog
|
- WebSocket: `/ws`
|
||||||
- `GET /api/quests` - Quest information
|
- Static assets: `/assets/*`
|
||||||
- `GET /api/transactions` - Economy data
|
- Built panel fallback: `panel/dist`
|
||||||
- `GET /api/health` - Health check
|
|
||||||
|
## Access model
|
||||||
|
|
||||||
|
Public:
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `/auth/discord`
|
||||||
|
- `/auth/callback`
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `GET /auth/me`
|
||||||
|
|
||||||
|
Player-accessible API routes:
|
||||||
|
|
||||||
|
- `GET /api/stats`
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/me`
|
||||||
|
- `GET /api/me/inventory`
|
||||||
|
|
||||||
|
Admin-only API routes:
|
||||||
|
|
||||||
|
- everything else under `/api/*`
|
||||||
|
|
||||||
|
Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login.
|
||||||
|
|
||||||
|
## Route summary
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- `GET /auth/discord`
|
||||||
|
- `GET /auth/callback`
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `GET /auth/me`
|
||||||
|
|
||||||
|
### Dashboard and system
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/stats`
|
||||||
|
- `GET /api/stats/activity`
|
||||||
|
- `POST /api/actions/reload-commands`
|
||||||
|
- `POST /api/actions/clear-cache`
|
||||||
|
- `POST /api/actions/maintenance-mode`
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- `GET /api/settings`
|
||||||
|
- `POST /api/settings`
|
||||||
|
- `GET /api/settings/meta`
|
||||||
|
- `GET /api/guilds/:guildId/settings`
|
||||||
|
- `PUT|PATCH /api/guilds/:guildId/settings`
|
||||||
|
- `DELETE /api/guilds/:guildId/settings`
|
||||||
|
|
||||||
|
### Users, classes, and inventory
|
||||||
|
|
||||||
|
- `GET /api/me`
|
||||||
|
- `GET /api/me/inventory`
|
||||||
|
- `GET /api/users`
|
||||||
|
- `GET /api/users/:id`
|
||||||
|
- `PUT /api/users/:id`
|
||||||
|
- `GET /api/users/:id/inventory`
|
||||||
|
- `POST /api/users/:id/inventory`
|
||||||
|
- `DELETE /api/users/:id/inventory/:itemId`
|
||||||
|
- `GET /api/classes`
|
||||||
|
- `POST /api/classes`
|
||||||
|
- `PUT /api/classes/:id`
|
||||||
|
- `DELETE /api/classes/:id`
|
||||||
|
|
||||||
|
### Game content
|
||||||
|
|
||||||
|
- `GET /api/items`
|
||||||
|
- `POST /api/items`
|
||||||
|
- `GET /api/items/:id`
|
||||||
|
- `PUT /api/items/:id`
|
||||||
|
- `DELETE /api/items/:id`
|
||||||
|
- `POST /api/items/:id/icon`
|
||||||
|
- `GET /api/quests`
|
||||||
|
- `POST /api/quests`
|
||||||
|
- `PUT /api/quests/:id`
|
||||||
|
- `DELETE /api/quests/:id`
|
||||||
|
- `GET /api/lootdrops`
|
||||||
|
- `POST /api/lootdrops`
|
||||||
|
- `DELETE /api/lootdrops/:messageId`
|
||||||
|
|
||||||
|
### Moderation and economy history
|
||||||
|
|
||||||
|
- `GET /api/moderation`
|
||||||
|
- `POST /api/moderation`
|
||||||
|
- `GET /api/transactions`
|
||||||
|
|
||||||
## WebSocket
|
## WebSocket
|
||||||
|
|
||||||
Connect to `/ws` for real-time updates:
|
`/ws` requires a valid `aurora_session` cookie.
|
||||||
- Stats broadcasts every 5 seconds
|
|
||||||
- Event notifications via system bus
|
Current behavior:
|
||||||
- PING/PONG heartbeat support
|
|
||||||
|
- dashboard clients subscribe to `dashboard`
|
||||||
|
- game clients also use lobby and room-scoped traffic through `GameServer`
|
||||||
|
- `PING` from the client returns `PONG`
|
||||||
|
- dashboard stats are broadcast every 5 seconds while at least one client is connected
|
||||||
|
- hard limits in `api/src/server.ts`:
|
||||||
|
- 200 concurrent connections
|
||||||
|
- 16 KB max payload
|
||||||
|
- 60 second idle timeout
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The API runs automatically when you start the bot:
|
Start the backend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at `http://localhost:3000`
|
Optional panel dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run panel:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `http://localhost:3000`.
|
||||||
|
|||||||
68
api/src/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# API layer
|
||||||
|
|
||||||
|
## Server shape
|
||||||
|
|
||||||
|
- Aurora uses Bun's native `serve()` API in `api/src/server.ts`.
|
||||||
|
- Route modules are aggregated in `api/src/routes/index.ts`.
|
||||||
|
- A route module returns `null` when it does not match so the dispatcher can continue.
|
||||||
|
- After route handling, the server tries `panel/dist` for SPA/static files.
|
||||||
|
|
||||||
|
## Authentication and authorization
|
||||||
|
|
||||||
|
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
||||||
|
- Sessions are stored in signed `aurora_session` cookies.
|
||||||
|
- Session TTL is 7 days.
|
||||||
|
- Login succeeds only for users already present in the `users` table.
|
||||||
|
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
||||||
|
- Redirects after login are intentionally restricted to localhost or relative paths.
|
||||||
|
|
||||||
|
Current access rules from `api/src/routes/index.ts`:
|
||||||
|
|
||||||
|
- public: `/auth/*`, `/api/health`
|
||||||
|
- player allow-list: `/api/stats`, `/api/health`, `/api/me`
|
||||||
|
- everything else under `/api/*`: admin-only
|
||||||
|
|
||||||
|
`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session.
|
||||||
|
|
||||||
|
## Response conventions
|
||||||
|
|
||||||
|
- `jsonResponse()` serializes `bigint` values as strings.
|
||||||
|
- `errorResponse()` returns `{ error, details? }`.
|
||||||
|
- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure.
|
||||||
|
- The API does not use a framework-level middleware stack; each route handles its own parsing and branching.
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
- Endpoint: `/ws`
|
||||||
|
- Requires an authenticated session
|
||||||
|
- Dashboard channel: `dashboard`
|
||||||
|
- Lobby channel: `lobby`
|
||||||
|
- Room-specific messaging is handled inside `GameServer`
|
||||||
|
- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected
|
||||||
|
- `NEW_EVENT` broadcasts are wired from `shared/lib/events`
|
||||||
|
|
||||||
|
Hard limits:
|
||||||
|
|
||||||
|
- max connections: 200
|
||||||
|
- max payload: 16 KB
|
||||||
|
- idle timeout: 60 seconds
|
||||||
|
|
||||||
|
## Static files
|
||||||
|
|
||||||
|
- Built panel assets are served from `panel/dist`
|
||||||
|
- `/assets/*` serves files from `bot/assets/graphics`
|
||||||
|
- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback
|
||||||
|
|
||||||
|
## Route notes
|
||||||
|
|
||||||
|
- `items.routes.ts` supports both JSON and multipart form data for item creation.
|
||||||
|
- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event.
|
||||||
|
- `guild-settings.routes.ts` invalidates the guild config cache after writes.
|
||||||
|
- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Some runtime caches are in-memory only and are lost on restart.
|
||||||
|
- The server registers game plugins at startup; duplicate registration throws.
|
||||||
|
- BigInt-safe JSON matters for nearly every domain route.
|
||||||
|
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
||||||
540
api/src/games/GameServer.ts
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import { RoomManager } from "./RoomManager";
|
||||||
|
import { GameWsClientSchema } from "./types";
|
||||||
|
import type { PlayerInfo } from "./types";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
import { TransactionType } from "@shared/lib/constants";
|
||||||
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
|
import type { Server, ServerWebSocket } from "bun";
|
||||||
|
|
||||||
|
export interface WsConnectionData {
|
||||||
|
session: { discordId: string; username: string; role: string };
|
||||||
|
rooms: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameServer {
|
||||||
|
readonly roomManager = new RoomManager();
|
||||||
|
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||||
|
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||||
|
private bunServer: Server<WsConnectionData> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Subscribe to room events and route them to the right clients
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => {
|
||||||
|
// The creating connection will subscribe itself; just broadcast room list
|
||||||
|
this.publishRoomListUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => {
|
||||||
|
// Send personalised state to each player
|
||||||
|
for (const [playerId, view] of playerViews) {
|
||||||
|
this.sendToPlayer(playerId, {
|
||||||
|
type: "GAME_STATE",
|
||||||
|
roomId,
|
||||||
|
state: view,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Broadcast started event with spectator view to the room channel
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "GAME_STARTED",
|
||||||
|
roomId,
|
||||||
|
state: spectatorView,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => {
|
||||||
|
// Each player gets their personalised view directly
|
||||||
|
for (const [playerId, view] of playerViews) {
|
||||||
|
this.sendToPlayer(playerId, {
|
||||||
|
type: "GAME_STATE",
|
||||||
|
roomId,
|
||||||
|
state: view,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Spectators/others get the spectator view via pub/sub
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "GAME_UPDATE",
|
||||||
|
roomId,
|
||||||
|
state: spectatorView,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => {
|
||||||
|
const room = this.roomManager.getRoom(roomId);
|
||||||
|
const betAmount = room?.betAmount ?? 0;
|
||||||
|
|
||||||
|
// Handle bet payouts asynchronously — broadcast happens after settlement
|
||||||
|
if (betAmount > 0) {
|
||||||
|
this.settleBets(roomId, winner, betAmount, payouts).then((payout) => {
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "GAME_ENDED",
|
||||||
|
roomId,
|
||||||
|
winner,
|
||||||
|
reason,
|
||||||
|
payout,
|
||||||
|
});
|
||||||
|
this.publishRoomListUpdate();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "GAME_ENDED",
|
||||||
|
roomId,
|
||||||
|
winner,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
this.publishRoomListUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => {
|
||||||
|
const room = this.roomManager.getRoom(roomId);
|
||||||
|
if (!room || room.betAmount <= 0) return;
|
||||||
|
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
||||||
|
const settlementDetails: typeof roundSettlements = {};
|
||||||
|
|
||||||
|
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
|
||||||
|
try {
|
||||||
|
if (settlement.payout > 0) {
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
playerId,
|
||||||
|
BigInt(settlement.payout),
|
||||||
|
TransactionType.GAME_WIN,
|
||||||
|
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
settlementDetails[playerId] = settlement;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(settlementDetails).length > 0) {
|
||||||
|
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "PLAYER_LEFT",
|
||||||
|
roomId,
|
||||||
|
playerId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
|
||||||
|
const channel = `room:${roomId}`;
|
||||||
|
for (const [, ws] of this.connections) {
|
||||||
|
if (ws.data.rooms.has(roomId)) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||||
|
ws.unsubscribe(channel);
|
||||||
|
ws.data.rooms.delete(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomManager.emitter.on("room:list:changed", () => {
|
||||||
|
this.publishRoomListUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setServer(server: Server<WsConnectionData>): void {
|
||||||
|
this.bunServer = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpen(ws: ServerWebSocket<WsConnectionData>): void {
|
||||||
|
const discordId = ws.data.session.discordId;
|
||||||
|
const existing = this.connections.get(discordId);
|
||||||
|
if (existing && existing !== ws) {
|
||||||
|
this.replacedConnections.set(discordId, existing);
|
||||||
|
}
|
||||||
|
this.connections.set(discordId, ws);
|
||||||
|
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
|
||||||
|
const parsed = GameWsClientSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = parsed.data;
|
||||||
|
const { discordId, username, role } = ws.data.session;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "CREATE_ROOM": {
|
||||||
|
// Solo mode forces betAmount to 0
|
||||||
|
const options = msg.options ? { ...msg.options } : {};
|
||||||
|
if (options.soloMode) options.betAmount = 0;
|
||||||
|
|
||||||
|
const result = this.roomManager.createRoom(msg.gameType, discordId, options);
|
||||||
|
if (!result.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.subscribe(`room:${result.roomId}`);
|
||||||
|
ws.data.rooms.add(result.roomId);
|
||||||
|
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
||||||
|
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
|
||||||
|
|
||||||
|
// Solo mode: auto-fill and start immediately
|
||||||
|
if (options.soloMode) {
|
||||||
|
const fillResult = this.roomManager.fillRoom(result.roomId, discordId);
|
||||||
|
if (!fillResult.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||||
|
}
|
||||||
|
// fillRoom with betAmount=0 calls startGame internally
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games
|
||||||
|
const plugin = gameRegistry.get(msg.gameType);
|
||||||
|
const createdRoom = this.roomManager.getRoom(result.roomId);
|
||||||
|
if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") {
|
||||||
|
if (createdRoom.betAmount > 0) {
|
||||||
|
this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws);
|
||||||
|
} else {
|
||||||
|
this.roomManager.startGame(result.roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "JOIN_ROOM": {
|
||||||
|
const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role);
|
||||||
|
if (!result.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.subscribe(`room:${msg.roomId}`);
|
||||||
|
ws.data.rooms.add(msg.roomId);
|
||||||
|
|
||||||
|
const room = this.roomManager.getRoom(msg.roomId);
|
||||||
|
const roomStatus = room?.status ?? "waiting";
|
||||||
|
|
||||||
|
// Determine the current state to send to this client
|
||||||
|
let state: unknown = undefined;
|
||||||
|
if (room && room.status === "playing") {
|
||||||
|
state = result.joinedAs === "spectator"
|
||||||
|
? this.roomManager.getSpectatorView(msg.roomId)
|
||||||
|
: this.roomManager.getPlayerView(msg.roomId, discordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player/spectator lists for JOIN_RESULT
|
||||||
|
const resolveInfo = (ids: string[]): PlayerInfo[] =>
|
||||||
|
ids.map(id => ({
|
||||||
|
discordId: id,
|
||||||
|
username: this.connections.get(id)?.data.session.username ?? id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const players = resolveInfo(room?.players ?? []);
|
||||||
|
const spectators = resolveInfo(Array.from(room?.spectators ?? new Set()));
|
||||||
|
|
||||||
|
// Notify replaced connection in the same room (multi-tab detection)
|
||||||
|
const replacedWs = this.replacedConnections.get(discordId);
|
||||||
|
if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) {
|
||||||
|
replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId }));
|
||||||
|
replacedWs.data.rooms.delete(msg.roomId);
|
||||||
|
replacedWs.unsubscribe(`room:${msg.roomId}`);
|
||||||
|
}
|
||||||
|
this.replacedConnections.delete(discordId);
|
||||||
|
|
||||||
|
// Build room options for the client
|
||||||
|
const roomOptions = room
|
||||||
|
? {
|
||||||
|
...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}),
|
||||||
|
...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Respond with JOIN_RESULT
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: "JOIN_RESULT",
|
||||||
|
roomId: msg.roomId,
|
||||||
|
joinedAs: result.joinedAs,
|
||||||
|
roomStatus,
|
||||||
|
players,
|
||||||
|
spectators,
|
||||||
|
state,
|
||||||
|
roomOptions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Notify other room members
|
||||||
|
const playerInfo: PlayerInfo = { discordId, username };
|
||||||
|
this.publish(`room:${msg.roomId}`, {
|
||||||
|
type: "PLAYER_JOINED",
|
||||||
|
roomId: msg.roomId,
|
||||||
|
player: playerInfo,
|
||||||
|
joinedAs: result.joinedAs,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
|
||||||
|
|
||||||
|
// Handle async bet deduction when room is ready to start
|
||||||
|
if (result.readyToStart && room) {
|
||||||
|
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "LEAVE_ROOM": {
|
||||||
|
this.roomManager.leaveRoom(msg.roomId, discordId);
|
||||||
|
ws.unsubscribe(`room:${msg.roomId}`);
|
||||||
|
ws.data.rooms.delete(msg.roomId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GAME_ACTION": {
|
||||||
|
// Action cost pre-check: deduct bet before processing split/double/place_bet
|
||||||
|
const actionRoom = this.roomManager.getRoom(msg.roomId);
|
||||||
|
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
|
||||||
|
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
|
||||||
|
if (actionPlugin?.getActionCost) {
|
||||||
|
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
|
||||||
|
if (cost > 0) {
|
||||||
|
const amount = actionRoom.betAmount * cost;
|
||||||
|
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
|
||||||
|
try {
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
discordId,
|
||||||
|
-BigInt(amount),
|
||||||
|
TransactionType.GAME_BET,
|
||||||
|
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
|
||||||
|
if (!result.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// game:updated event handler will dispatch views to players and spectators
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "START_GAME": {
|
||||||
|
const room = this.roomManager.getRoom(msg.roomId);
|
||||||
|
if (!room) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.host !== discordId) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.status !== "waiting") {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startPlugin = gameRegistry.get(room.gameSlug);
|
||||||
|
if (startPlugin && room.players.length < startPlugin.minPlayers) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.betAmount > 0) {
|
||||||
|
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||||
|
} else {
|
||||||
|
const startResult = this.roomManager.startGame(msg.roomId);
|
||||||
|
if (!startResult.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "FILL_ROOM": {
|
||||||
|
if (role !== "admin") {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
|
||||||
|
if (!fillResult.ok) {
|
||||||
|
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fillResult.readyToStart) {
|
||||||
|
const room = this.roomManager.getRoom(msg.roomId);
|
||||||
|
if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||||
|
}
|
||||||
|
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose(ws: ServerWebSocket<WsConnectionData>): void {
|
||||||
|
// If this is a replaced (displaced) connection, just clean up the registry and stop
|
||||||
|
for (const [id, prevWs] of this.replacedConnections) {
|
||||||
|
if (prevWs === ws) {
|
||||||
|
this.replacedConnections.delete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const roomId of ws.data.rooms) {
|
||||||
|
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
|
||||||
|
}
|
||||||
|
this.connections.delete(ws.data.session.discordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduct bet amounts from all players, then start the game.
|
||||||
|
* If any player can't afford the bet, refund already-deducted players
|
||||||
|
* and remove the failing player from the room.
|
||||||
|
*/
|
||||||
|
private async deductBetsAndStart(
|
||||||
|
roomId: string,
|
||||||
|
betAmount: number,
|
||||||
|
playerIds: string[],
|
||||||
|
triggeringWs: ServerWebSocket<WsConnectionData>,
|
||||||
|
): Promise<void> {
|
||||||
|
const room = this.roomManager.getRoom(roomId);
|
||||||
|
if (!room || room.betsPending) return;
|
||||||
|
|
||||||
|
// Games with getActionCost handle per-round betting themselves (e.g., blackjack).
|
||||||
|
// Skip the upfront deduction — just start the game.
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug);
|
||||||
|
if (plugin?.getActionCost) {
|
||||||
|
const startResult = this.roomManager.startGame(roomId);
|
||||||
|
if (!startResult.ok) {
|
||||||
|
triggeringWs.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
room.betsPending = true;
|
||||||
|
|
||||||
|
const uniquePlayers = [...new Set(playerIds)];
|
||||||
|
const deducted: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug;
|
||||||
|
for (const pid of uniquePlayers) {
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
pid,
|
||||||
|
-BigInt(betAmount),
|
||||||
|
TransactionType.GAME_BET,
|
||||||
|
`${gameName} wager (room ${roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
deducted.push(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All deductions succeeded — start the game
|
||||||
|
const startResult = this.roomManager.startGame(roomId);
|
||||||
|
if (!startResult.ok) {
|
||||||
|
// Shouldn't happen, but refund if it does
|
||||||
|
await this.refundPlayers(deducted, betAmount, roomId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Refund anyone already deducted
|
||||||
|
await this.refundPlayers(deducted, betAmount, roomId);
|
||||||
|
|
||||||
|
// Find the player who couldn't afford the bet
|
||||||
|
const failedPlayer = uniquePlayers.find(p => !deducted.includes(p));
|
||||||
|
if (failedPlayer) {
|
||||||
|
this.roomManager.removePlayer(roomId, failedPlayer);
|
||||||
|
this.sendToPlayer(failedPlayer, {
|
||||||
|
type: "ERROR",
|
||||||
|
message: "Insufficient funds for the bet. You have been removed from the room.",
|
||||||
|
});
|
||||||
|
this.publish(`room:${roomId}`, {
|
||||||
|
type: "PLAYER_LEFT",
|
||||||
|
roomId,
|
||||||
|
playerId: failedPlayer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`);
|
||||||
|
} finally {
|
||||||
|
if (room) room.betsPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pay out winnings or refund bets on game end. */
|
||||||
|
private async settleBets(
|
||||||
|
roomId: string,
|
||||||
|
winner: string | null,
|
||||||
|
betAmount: number,
|
||||||
|
payouts?: Record<string, number>,
|
||||||
|
): Promise<{ amount: number; refunded?: boolean }> {
|
||||||
|
const room = this.roomManager.getRoom(roomId);
|
||||||
|
const uniquePlayers = [...new Set(room?.players ?? [])];
|
||||||
|
const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Custom payouts override default pot logic (used by house-edge games like blackjack)
|
||||||
|
if (payouts) {
|
||||||
|
let totalPaid = 0;
|
||||||
|
for (const [playerId, multiplier] of Object.entries(payouts)) {
|
||||||
|
if (multiplier <= 0) continue;
|
||||||
|
const amount = Math.floor(betAmount * multiplier);
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
playerId,
|
||||||
|
BigInt(amount),
|
||||||
|
TransactionType.GAME_WIN,
|
||||||
|
`${gameName} payout (room ${roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
totalPaid = Math.max(totalPaid, amount);
|
||||||
|
}
|
||||||
|
const isRefund = !winner && totalPaid === betAmount;
|
||||||
|
return { amount: totalPaid, refunded: isRefund };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default pot logic: winner takes all, draw refunds everyone
|
||||||
|
const pot = betAmount * uniquePlayers.length;
|
||||||
|
if (winner) {
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
winner,
|
||||||
|
BigInt(pot),
|
||||||
|
TransactionType.GAME_WIN,
|
||||||
|
`${gameName} wager won (room ${roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
return { amount: pot };
|
||||||
|
} else {
|
||||||
|
await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName);
|
||||||
|
return { amount: betAmount, refunded: true };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`);
|
||||||
|
return { amount: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
|
||||||
|
for (const pid of playerIds) {
|
||||||
|
try {
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
pid,
|
||||||
|
BigInt(betAmount),
|
||||||
|
TransactionType.GAME_WIN,
|
||||||
|
`${gameName} wager refund (room ${roomId.slice(0, 8)})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(channel: string, message: unknown): void {
|
||||||
|
this.bunServer?.publish(channel, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendToPlayer(discordId: string, message: unknown): void {
|
||||||
|
this.connections.get(discordId)?.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishRoomListUpdate(): void {
|
||||||
|
this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameServer = new GameServer();
|
||||||
187
api/src/games/RoomManager.test.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "bun:test";
|
||||||
|
import { RoomManager } from "./RoomManager";
|
||||||
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
|
import type { GamePlugin } from "@shared/games/types";
|
||||||
|
|
||||||
|
// Minimal stub plugin for testing the room system
|
||||||
|
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
|
||||||
|
slug: "stub",
|
||||||
|
name: "Stub Game",
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 2,
|
||||||
|
createInitialState: (players) => ({ turn: 0 }),
|
||||||
|
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
|
||||||
|
getPlayerView: (state) => state,
|
||||||
|
getSpectatorView: (state) => state,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!gameRegistry.get("stub")) {
|
||||||
|
gameRegistry.register(stubPlugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoomManager", () => {
|
||||||
|
let manager: RoomManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new RoomManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRoom", () => {
|
||||||
|
it("should create a room and return its id", () => {
|
||||||
|
const result = manager.createRoom("stub", "player1");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.roomId).toBeDefined();
|
||||||
|
expect(typeof result.roomId).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject unknown game type", () => {
|
||||||
|
const result = manager.createRoom("unknown-game", "player1");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add creator as first player", () => {
|
||||||
|
const result = manager.createRoom("stub", "player1");
|
||||||
|
if (result.ok) {
|
||||||
|
const room = manager.getRoom(result.roomId);
|
||||||
|
expect(room?.players).toContain("player1");
|
||||||
|
expect(room?.host).toBe("player1");
|
||||||
|
expect(room?.status).toBe("waiting");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("joinRoom", () => {
|
||||||
|
it("should add a player to a waiting room", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
expect(join.ok).toBe(true);
|
||||||
|
if (join.ok) {
|
||||||
|
expect(join.joinedAs).toBe("player");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should auto-start when room reaches maxPlayers", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
const room = manager.getRoom(create.roomId);
|
||||||
|
expect(room?.status).toBe("playing");
|
||||||
|
expect(room?.state).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow joining as spectator when game is playing", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||||
|
expect(spec.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should downgrade to spectator when joining full room as player", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
const result = manager.joinRoom(create.roomId, "player3", "player");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.joinedAs).toBe("spectator");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject joining nonexistent room", () => {
|
||||||
|
const result = manager.joinRoom("fake-id", "player1", "player");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleAction", () => {
|
||||||
|
it("should apply a valid game action", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject action from spectator", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||||
|
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leaveRoom", () => {
|
||||||
|
it("should remove a player from the room", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.leaveRoom(create.roomId, "player1");
|
||||||
|
const room = manager.getRoom(create.roomId);
|
||||||
|
expect(room).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a spectator from the room", () => {
|
||||||
|
const create = manager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
manager.joinRoom(create.roomId, "player2", "player");
|
||||||
|
manager.joinRoom(create.roomId, "spec1", "spectator");
|
||||||
|
manager.leaveRoom(create.roomId, "spec1");
|
||||||
|
const room = manager.getRoom(create.roomId);
|
||||||
|
expect(room?.spectators.has("spec1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listRooms", () => {
|
||||||
|
it("should return summaries of all rooms", () => {
|
||||||
|
manager.createRoom("stub", "player1");
|
||||||
|
manager.createRoom("stub", "player2");
|
||||||
|
const rooms = manager.listRooms();
|
||||||
|
expect(rooms.length).toBe(2);
|
||||||
|
expect(rooms[0].gameSlug).toBe("stub");
|
||||||
|
expect(rooms[0].status).toBe("waiting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by game type", () => {
|
||||||
|
manager.createRoom("stub", "player1");
|
||||||
|
const rooms = manager.listRooms("stub");
|
||||||
|
expect(rooms.length).toBe(1);
|
||||||
|
const empty = manager.listRooms("blackjack");
|
||||||
|
expect(empty.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("waiting room cleanup", () => {
|
||||||
|
it("should remove waiting rooms after the configured timeout", async () => {
|
||||||
|
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 20 });
|
||||||
|
const create = shortLivedManager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 35));
|
||||||
|
|
||||||
|
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refresh the waiting room timeout when the room is active", async () => {
|
||||||
|
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 25 });
|
||||||
|
const create = shortLivedManager.createRoom("stub", "player1");
|
||||||
|
if (!create.ok) throw new Error("Failed to create room");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 15));
|
||||||
|
|
||||||
|
const spectatorJoin = shortLivedManager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||||
|
expect(spectatorJoin.ok).toBe(true);
|
||||||
|
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 15));
|
||||||
|
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
327
api/src/games/RoomManager.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import mitt from "mitt";
|
||||||
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
|
import type { Room, RoomSummary } from "./types";
|
||||||
|
import type { RoundSettlement } from "@shared/games/types";
|
||||||
|
|
||||||
|
const DEFAULT_ROOM_CONFIG = {
|
||||||
|
WAITING_CLEANUP_MS: 15 * 60_000,
|
||||||
|
FINISHED_CLEANUP_MS: 60_000,
|
||||||
|
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RoomManagerConfig = typeof DEFAULT_ROOM_CONFIG;
|
||||||
|
|
||||||
|
type ActionResult =
|
||||||
|
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||||
|
type JoinResult =
|
||||||
|
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
|
||||||
|
|
||||||
|
type RoomEvents = {
|
||||||
|
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
||||||
|
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
|
||||||
|
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||||
|
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||||
|
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||||
|
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
|
||||||
|
"player:left": { roomId: string; playerId: string };
|
||||||
|
"room:deleted": { roomId: string };
|
||||||
|
"room:list:changed": void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RoomManager {
|
||||||
|
private rooms = new Map<string, Room>();
|
||||||
|
private cleanupTimers = new Map<string, Timer>();
|
||||||
|
private readonly config: RoomManagerConfig;
|
||||||
|
readonly emitter = mitt<RoomEvents>();
|
||||||
|
|
||||||
|
constructor(config: Partial<RoomManagerConfig> = {}) {
|
||||||
|
this.config = { ...DEFAULT_ROOM_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): CreateResult {
|
||||||
|
const plugin = gameRegistry.get(gameSlug);
|
||||||
|
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
|
||||||
|
const room: Room = {
|
||||||
|
id,
|
||||||
|
gameSlug,
|
||||||
|
host: hostId,
|
||||||
|
players: [hostId],
|
||||||
|
spectators: new Set(),
|
||||||
|
state: null,
|
||||||
|
status: "waiting",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
options,
|
||||||
|
betAmount,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rooms.set(id, room);
|
||||||
|
this.refreshWaitingCleanup(id, room);
|
||||||
|
|
||||||
|
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
|
||||||
|
return { ok: true, roomId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
|
||||||
|
// Reconnecting player: must be checked before the in-progress spectator guard.
|
||||||
|
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
||||||
|
room.spectators.delete(playerId);
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||||
|
room.spectators.add(playerId);
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
|
||||||
|
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||||
|
// Downgrade to spectator — room is full but still waiting, so game hasn't started
|
||||||
|
room.spectators.add(playerId);
|
||||||
|
return { ok: true, joinedAs: "spectator", started: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
room.players.push(playerId);
|
||||||
|
|
||||||
|
if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) {
|
||||||
|
// Defer start when bets are involved — GameServer handles async deduction first
|
||||||
|
if (room.betAmount > 0) {
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
|
||||||
|
}
|
||||||
|
this.startGame(roomId);
|
||||||
|
return { ok: true, joinedAs: "player", started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
return { ok: true, joinedAs: "player", started: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
|
||||||
|
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
|
||||||
|
// Spectator-to-player promotion for actions like "sit_down"
|
||||||
|
if (!room.players.includes(playerId)) {
|
||||||
|
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
|
||||||
|
room.spectators.delete(playerId);
|
||||||
|
room.players.push(playerId);
|
||||||
|
} else {
|
||||||
|
return { ok: false, error: "You are not a player in this game" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = plugin.handleAction(room.state, action, playerId);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
|
||||||
|
room.state = result.state;
|
||||||
|
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||||
|
if (gameOver) {
|
||||||
|
room.status = "finished";
|
||||||
|
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spectatorView = plugin.getSpectatorView(room.state);
|
||||||
|
const playerViews = new Map<string, unknown>();
|
||||||
|
for (const pid of new Set(room.players)) {
|
||||||
|
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||||
|
}
|
||||||
|
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||||
|
|
||||||
|
// Emit round payouts for mid-game settlement (continuous-play games)
|
||||||
|
if (result.roundSettlements && !gameOver) {
|
||||||
|
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameOver) {
|
||||||
|
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements };
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveRoom(roomId: string, playerId: string): void {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
room.spectators.delete(playerId);
|
||||||
|
|
||||||
|
const playerIdx = room.players.indexOf(playerId);
|
||||||
|
if (playerIdx !== -1) {
|
||||||
|
room.players.splice(playerIdx, 1);
|
||||||
|
|
||||||
|
if (room.status === "playing") {
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
if (plugin.onPlayerDisconnect) {
|
||||||
|
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||||
|
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||||
|
if (gameOver) {
|
||||||
|
room.status = "finished";
|
||||||
|
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||||
|
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.players.length === 0 && room.status === "waiting") {
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
|
||||||
|
this.emitter.emit("player:left", { roomId, playerId });
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills empty seats with the admin's own ID for solo testing.
|
||||||
|
* This means `createInitialState` will receive duplicate player IDs
|
||||||
|
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
||||||
|
* solo-test mode produces non-unique player arrays.
|
||||||
|
*/
|
||||||
|
fillRoom(roomId: string, adminId: string): FillResult {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||||
|
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
|
||||||
|
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
while (room.players.length < plugin.maxPlayers) {
|
||||||
|
room.players.push(adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer start when bets are involved
|
||||||
|
if (room.betAmount > 0) {
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
return { ok: true, readyToStart: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startGame(roomId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize game state and transition room to playing. */
|
||||||
|
startGame(roomId: string): { ok: true } | { ok: false; error: string } {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room) return { ok: false, error: "Room not found" };
|
||||||
|
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||||
|
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
room.state = plugin.createInitialState(room.players, room.options);
|
||||||
|
room.status = "playing";
|
||||||
|
this.scheduleCleanup(roomId, this.config.PLAYING_MAX_MS);
|
||||||
|
|
||||||
|
const spectatorView = plugin.getSpectatorView(room.state);
|
||||||
|
const playerViews = new Map<string, unknown>();
|
||||||
|
for (const pid of new Set(room.players)) {
|
||||||
|
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||||
|
}
|
||||||
|
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a player from a waiting room (used when bet deduction fails). */
|
||||||
|
removePlayer(roomId: string, playerId: string): void {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room || room.status !== "waiting") return;
|
||||||
|
const idx = room.players.indexOf(playerId);
|
||||||
|
if (idx !== -1) room.players.splice(idx, 1);
|
||||||
|
if (room.players.length === 0) {
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.refreshWaitingCleanup(roomId, room);
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoom(roomId: string): Room | undefined {
|
||||||
|
return this.rooms.get(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listRooms(gameSlug?: string): RoomSummary[] {
|
||||||
|
const summaries: RoomSummary[] = [];
|
||||||
|
for (const room of this.rooms.values()) {
|
||||||
|
if (gameSlug && room.gameSlug !== gameSlug) continue;
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug);
|
||||||
|
summaries.push({
|
||||||
|
id: room.id,
|
||||||
|
gameSlug: room.gameSlug,
|
||||||
|
gameName: plugin?.name ?? room.gameSlug,
|
||||||
|
host: room.host,
|
||||||
|
playerCount: room.players.length,
|
||||||
|
maxPlayers: plugin?.maxPlayers ?? 0,
|
||||||
|
spectatorCount: room.spectators.size,
|
||||||
|
status: room.status,
|
||||||
|
betAmount: room.betAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerView(roomId: string, playerId: string): unknown {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room || !room.state) return null;
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
return plugin.getPlayerView(room.state, playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpectatorView(roomId: string): unknown {
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (!room || !room.state) return null;
|
||||||
|
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||||
|
return plugin.getSpectatorView(room.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleCleanup(roomId: string, ms: number): void {
|
||||||
|
this.clearCleanup(roomId);
|
||||||
|
const timer = setTimeout(() => this.deleteRoom(roomId), ms);
|
||||||
|
this.cleanupTimers.set(roomId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshWaitingCleanup(roomId: string, room: Room): void {
|
||||||
|
if (room.status !== "waiting") return;
|
||||||
|
this.scheduleCleanup(roomId, this.config.WAITING_CLEANUP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearCleanup(roomId: string): void {
|
||||||
|
const existing = this.cleanupTimers.get(roomId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
this.cleanupTimers.delete(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteRoom(roomId: string): void {
|
||||||
|
this.clearCleanup(roomId);
|
||||||
|
if (this.rooms.delete(roomId)) {
|
||||||
|
this.emitter.emit("room:deleted", { roomId });
|
||||||
|
this.emitter.emit("room:list:changed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
api/src/games/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { RoundSettlement } from "@shared/games/types";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
gameSlug: string;
|
||||||
|
host: string;
|
||||||
|
players: string[];
|
||||||
|
spectators: Set<string>;
|
||||||
|
state: unknown;
|
||||||
|
status: "waiting" | "playing" | "finished";
|
||||||
|
createdAt: number;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
betAmount: number;
|
||||||
|
/** Guard against double bet-deduction when two joins race */
|
||||||
|
betsPending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSummary {
|
||||||
|
id: string;
|
||||||
|
gameSlug: string;
|
||||||
|
gameName: string;
|
||||||
|
host: string;
|
||||||
|
playerCount: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
spectatorCount: number;
|
||||||
|
status: "waiting" | "playing" | "finished";
|
||||||
|
betAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerInfo {
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string(), options: z.looseObject({}).optional() }),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("JOIN_ROOM"),
|
||||||
|
roomId: z.string(),
|
||||||
|
preferAs: z.enum(["player", "spectator"]),
|
||||||
|
role: z.enum(["player", "admin"]).optional(),
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||||
|
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
|
||||||
|
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
|
||||||
|
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
|
||||||
|
z.object({ type: z.literal("START_GAME"), roomId: z.string() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
|
||||||
|
|
||||||
|
export type GameWsServerMessage =
|
||||||
|
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
||||||
|
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
||||||
|
| { type: "GAME_UPDATE"; roomId: string; state: unknown }
|
||||||
|
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
||||||
|
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||||
|
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
||||||
|
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
|
||||||
|
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
|
||||||
|
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } }
|
||||||
|
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
|
||||||
|
| { type: "SESSION_REPLACED"; roomId: string }
|
||||||
|
| { type: "ERROR"; message: string };
|
||||||
@@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return new Response(file, {
|
return new Response(file, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
"Cache-Control": "no-cache",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
103
api/src/routes/auth.routes.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
|
||||||
|
const findFirst = mock(async () => ({ id: 123n }));
|
||||||
|
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
query: {
|
||||||
|
users: {
|
||||||
|
findFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: () => { },
|
||||||
|
info: () => { },
|
||||||
|
warn: () => { },
|
||||||
|
debug: () => { },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authRoutes, getSession } from "./auth.routes";
|
||||||
|
|
||||||
|
describe("Auth Routes", () => {
|
||||||
|
let fetchSpy: ReturnType<typeof spyOn> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.DISCORD_CLIENT_ID = "client-id";
|
||||||
|
process.env.DISCORD_CLIENT_SECRET = "client-secret";
|
||||||
|
process.env.SESSION_SECRET = "session-secret";
|
||||||
|
process.env.PANEL_BASE_URL = "http://localhost:3000";
|
||||||
|
process.env.ADMIN_USER_IDS = "123";
|
||||||
|
findFirst.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchSpy?.mockRestore();
|
||||||
|
fetchSpy = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a signed session cookie during OAuth callback", async () => {
|
||||||
|
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
|
||||||
|
const loginRes = await authRoutes.handler({
|
||||||
|
req: new Request(loginUrl, { method: "GET" }),
|
||||||
|
url: loginUrl,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/auth/discord",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loginRes?.status).toBe(302);
|
||||||
|
|
||||||
|
const redirectLocation = loginRes?.headers.get("Location");
|
||||||
|
expect(redirectLocation).not.toBeNull();
|
||||||
|
|
||||||
|
const state = new URL(redirectLocation!).searchParams.get("state");
|
||||||
|
expect(state).not.toBeNull();
|
||||||
|
|
||||||
|
fetchSpy = spyOn(globalThis, "fetch");
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: "123",
|
||||||
|
username: "aurora-admin",
|
||||||
|
avatar: null,
|
||||||
|
}), { status: 200 }));
|
||||||
|
|
||||||
|
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
|
||||||
|
const callbackRes = await authRoutes.handler({
|
||||||
|
req: new Request(callbackUrl, { method: "GET" }),
|
||||||
|
url: callbackUrl,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/auth/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackRes?.status).toBe(302);
|
||||||
|
expect(callbackRes?.headers.get("Location")).toBe("/admin");
|
||||||
|
|
||||||
|
const setCookie = callbackRes?.headers.get("Set-Cookie");
|
||||||
|
expect(setCookie).toContain("aurora_session=");
|
||||||
|
|
||||||
|
const sessionCookie = setCookie!.split(";")[0]!;
|
||||||
|
const session = getSession(new Request("http://localhost/api/me", {
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(session).toEqual({
|
||||||
|
discordId: "123",
|
||||||
|
username: "aurora-admin",
|
||||||
|
avatar: null,
|
||||||
|
role: "admin",
|
||||||
|
expiresAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tampered session cookies", () => {
|
||||||
|
const session = getSession(new Request("http://localhost/api/me", {
|
||||||
|
headers: { cookie: "aurora_session=not-a-valid-token" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,22 +3,39 @@
|
|||||||
* Handles login flow, callback, logout, and session management.
|
* Handles login flow, callback, logout, and session management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
import { jsonResponse, errorResponse } from "./utils";
|
import { jsonResponse, errorResponse } from "./utils";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { users } from "@shared/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
// In-memory session store: token → { discordId, username, avatar, expiresAt }
|
// Signed session payload stored in the aurora_session cookie.
|
||||||
export interface Session {
|
export interface Session {
|
||||||
discordId: string;
|
discordId: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
role: "admin" | "player";
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new Map<string, Session>();
|
interface SessionTokenPayload extends Session {
|
||||||
const redirects = new Map<string, string>(); // redirect token -> return_to URL
|
v: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthStatePayload {
|
||||||
|
exp: number;
|
||||||
|
returnTo: string;
|
||||||
|
v: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE_NAME = "aurora_session";
|
||||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const TOKEN_NAMESPACE = "aurora.auth";
|
||||||
|
const TOKEN_VERSION = "v1";
|
||||||
|
|
||||||
function getEnv(key: string): string {
|
function getEnv(key: string): string {
|
||||||
const val = process.env[key];
|
const val = process.env[key];
|
||||||
@@ -26,15 +43,70 @@ function getEnv(key: string): string {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionSecret(required: boolean = false): string | null {
|
||||||
|
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
|
||||||
|
if (!secret && required) {
|
||||||
|
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSessionSecret(): string {
|
||||||
|
return getSessionSecret(true)!;
|
||||||
|
}
|
||||||
|
|
||||||
function getAdminIds(): string[] {
|
function getAdminIds(): string[] {
|
||||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateToken(): string {
|
function encodeBase64Url(value: string): string {
|
||||||
const bytes = new Uint8Array(32);
|
return Buffer.from(value, "utf8").toString("base64url");
|
||||||
crypto.getRandomValues(bytes);
|
}
|
||||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
function decodeBase64Url(value: string): string {
|
||||||
|
return Buffer.from(value, "base64url").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signValue(kind: string, encodedPayload: string, secret: string): string {
|
||||||
|
return createHmac("sha256", secret)
|
||||||
|
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
|
||||||
|
.digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
|
||||||
|
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||||
|
const signature = signValue(kind, encodedPayload, secret);
|
||||||
|
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const secret = getSessionSecret();
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const version = parts[0];
|
||||||
|
const encodedPayload = parts[1];
|
||||||
|
const providedSignature = parts[2];
|
||||||
|
if (version !== TOKEN_VERSION) return null;
|
||||||
|
if (!encodedPayload || !providedSignature) return null;
|
||||||
|
|
||||||
|
const expectedSignature = signValue(kind, encodedPayload, secret);
|
||||||
|
const providedBuffer = Buffer.from(providedSignature);
|
||||||
|
const expectedBuffer = Buffer.from(expectedSignature);
|
||||||
|
|
||||||
|
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||||
|
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
@@ -51,18 +123,65 @@ function parseCookies(header: string | null): Record<string, string> {
|
|||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
|
||||||
|
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
|
||||||
|
return rawReturnTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(rawReturnTo, baseUrl);
|
||||||
|
const allowedBase = new URL(baseUrl);
|
||||||
|
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||||
|
|
||||||
|
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCookieAttributes(maxAgeSeconds?: number): string {
|
||||||
|
const attrs = [
|
||||||
|
"Path=/",
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Lax",
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (new URL(getBaseUrl()).protocol === "https:") {
|
||||||
|
attrs.push("Secure");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxAgeSeconds === "number") {
|
||||||
|
attrs.push(`Max-Age=${maxAgeSeconds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
/** Get session from request cookie */
|
/** Get session from request cookie */
|
||||||
export function getSession(req: Request): Session | null {
|
export function getSession(req: Request): Session | null {
|
||||||
const cookies = parseCookies(req.headers.get("cookie"));
|
const cookies = parseCookies(req.headers.get("cookie"));
|
||||||
const token = cookies["aurora_session"];
|
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
|
||||||
if (!token) return null;
|
|
||||||
const session = sessions.get(token);
|
if (!payload || payload.v !== 1) return null;
|
||||||
if (!session) return null;
|
if (Date.now() > payload.expiresAt) return null;
|
||||||
if (Date.now() > session.expiresAt) {
|
|
||||||
sessions.delete(token);
|
return {
|
||||||
return null;
|
discordId: payload.discordId,
|
||||||
}
|
username: payload.username,
|
||||||
return session;
|
avatar: payload.avatar,
|
||||||
|
role: payload.role,
|
||||||
|
expiresAt: payload.expiresAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if request is authenticated as admin */
|
/** Check if request is authenticated as admin */
|
||||||
@@ -80,20 +199,22 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||||
const scope = "identify+email";
|
const scope = "identify+email";
|
||||||
|
const secret = requireSessionSecret();
|
||||||
|
|
||||||
// Store return_to URL if provided
|
// Store return_to URL in signed OAuth state
|
||||||
const returnTo = ctx.url.searchParams.get("return_to") || "/";
|
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
|
||||||
const redirectToken = generateToken();
|
const state = serializeSignedToken("oauth", {
|
||||||
redirects.set(redirectToken, returnTo);
|
exp: Date.now() + OAUTH_STATE_MAX_AGE,
|
||||||
|
returnTo,
|
||||||
|
v: 1,
|
||||||
|
}, secret);
|
||||||
|
|
||||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
|
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
|
||||||
|
|
||||||
// Set a temporary cookie with the redirect token
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: url,
|
Location: url,
|
||||||
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -112,8 +233,13 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const redirectUri = `${baseUrl}/auth/callback`;
|
const redirectUri = `${baseUrl}/auth/callback`;
|
||||||
|
const secret = requireSessionSecret();
|
||||||
|
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
|
||||||
|
|
||||||
|
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
|
||||||
|
return errorResponse("Invalid OAuth state", 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange code for token
|
|
||||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -144,49 +270,41 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
||||||
|
|
||||||
// Check allowlist
|
// Check enrollment — user must exist in the users table
|
||||||
const adminIds = getAdminIds();
|
const dbUser = await DrizzleClient.query.users.findFirst({
|
||||||
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
|
where: eq(users.id, BigInt(user.id)),
|
||||||
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
|
||||||
return new Response(
|
return new Response(
|
||||||
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
|
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
|
||||||
{ status: 403, headers: { "Content-Type": "text/html" } }
|
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Determine role
|
||||||
const token = generateToken();
|
const adminIds = getAdminIds();
|
||||||
sessions.set(token, {
|
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
||||||
|
|
||||||
|
// Create signed session cookie
|
||||||
|
const sessionToken = serializeSignedToken("session", {
|
||||||
discordId: user.id,
|
discordId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
|
role,
|
||||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||||
});
|
v: 1,
|
||||||
|
}, secret);
|
||||||
|
|
||||||
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
|
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
||||||
|
|
||||||
// Get return_to URL from redirect token cookie
|
|
||||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
|
||||||
const redirectToken = cookies["aurora_redirect"];
|
|
||||||
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
|
|
||||||
if (redirectToken) redirects.delete(redirectToken);
|
|
||||||
|
|
||||||
// Only allow redirects to localhost or relative paths (prevent open redirect)
|
|
||||||
try {
|
|
||||||
const parsed = new URL(returnTo, baseUrl);
|
|
||||||
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
|
||||||
returnTo = "/";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
returnTo = "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to panel with session cookie
|
// Redirect to panel with session cookie
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: returnTo,
|
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
|
||||||
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
|
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,14 +315,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
// POST /auth/logout — clear session
|
// POST /auth/logout — clear session
|
||||||
if (pathname === "/auth/logout" && method === "POST") {
|
if (pathname === "/auth/logout" && method === "POST") {
|
||||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
|
||||||
const token = cookies["aurora_session"];
|
|
||||||
if (token) sessions.delete(token);
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -213,13 +327,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
// GET /auth/me — return current session info
|
// GET /auth/me — return current session info
|
||||||
if (pathname === "/auth/me" && method === "GET") {
|
if (pathname === "/auth/me" && method === "GET") {
|
||||||
const session = getSession(ctx.req);
|
const session = getSession(ctx.req);
|
||||||
if (!session) return jsonResponse({ authenticated: false }, 401);
|
if (!session) return jsonResponse({ authenticated: false, enrolled: true });
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
enrolled: true,
|
||||||
user: {
|
user: {
|
||||||
discordId: session.discordId,
|
discordId: session.discordId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
avatar: session.avatar,
|
avatar: session.avatar,
|
||||||
|
role: session.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
114
api/src/routes/index.authz.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
|
||||||
|
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
mock.module("./auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
getSession: () => currentSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("./health.routes", () => ({
|
||||||
|
healthRoutes: {
|
||||||
|
name: "health",
|
||||||
|
handler: ({ pathname }: { pathname: string }) =>
|
||||||
|
pathname === "/api/health"
|
||||||
|
? Response.json({ status: "ok" }, { status: 200 })
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("./stats.routes", () => ({
|
||||||
|
statsRoutes: {
|
||||||
|
name: "stats",
|
||||||
|
handler: ({ pathname }: { pathname: string }) =>
|
||||||
|
pathname === "/api/stats"
|
||||||
|
? Response.json({ ok: true }, { status: 200 })
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
|
||||||
|
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
|
||||||
|
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
|
||||||
|
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
|
||||||
|
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
|
||||||
|
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
|
||||||
|
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
|
||||||
|
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
|
||||||
|
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
|
||||||
|
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
|
||||||
|
mock.module("@shared/modules/user/user.service", () => ({
|
||||||
|
userService: {
|
||||||
|
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||||
|
inventoryService: {
|
||||||
|
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("@shared/lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: () => { },
|
||||||
|
info: () => { },
|
||||||
|
warn: () => { },
|
||||||
|
debug: () => { },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleRequest } from "./index";
|
||||||
|
|
||||||
|
describe("Route Authorization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
currentSession = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated protected API requests", async () => {
|
||||||
|
const url = new URL("http://localhost/api/users/123");
|
||||||
|
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||||
|
|
||||||
|
expect(res?.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks players from admin user routes", async () => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "123",
|
||||||
|
username: "player",
|
||||||
|
role: "player",
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL("http://localhost/api/users/456");
|
||||||
|
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||||
|
|
||||||
|
expect(res?.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows players to access self-service API routes", async () => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "123",
|
||||||
|
username: "player",
|
||||||
|
role: "player",
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL("http://localhost/api/me/inventory");
|
||||||
|
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||||
|
|
||||||
|
expect(res?.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows admins to access admin user routes", async () => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "1",
|
||||||
|
username: "admin",
|
||||||
|
role: "admin",
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL("http://localhost/api/users/456");
|
||||||
|
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||||
|
|
||||||
|
expect(res?.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
import { authRoutes, isAuthenticated } from "./auth.routes";
|
import { authRoutes, getSession } from "./auth.routes";
|
||||||
import { healthRoutes } from "./health.routes";
|
import { healthRoutes } from "./health.routes";
|
||||||
import { statsRoutes } from "./stats.routes";
|
import { statsRoutes } from "./stats.routes";
|
||||||
import { actionsRoutes } from "./actions.routes";
|
import { actionsRoutes } from "./actions.routes";
|
||||||
@@ -70,9 +70,18 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
|||||||
|
|
||||||
// For API routes, enforce authentication
|
// For API routes, enforce authentication
|
||||||
if (ctx.pathname.startsWith("/api/")) {
|
if (ctx.pathname.startsWith("/api/")) {
|
||||||
if (!isAuthenticated(req)) {
|
const session = getSession(req);
|
||||||
|
if (!session) {
|
||||||
return errorResponse("Unauthorized", 401);
|
return errorResponse("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Player routes are explicitly allow-listed. Everything else is admin-only.
|
||||||
|
const playerAllowedPrefixes = ["/api/stats", "/api/health", "/api/me"];
|
||||||
|
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
|
||||||
|
|
||||||
|
if (session.role === "player" && !isPlayerAllowed) {
|
||||||
|
return errorResponse("Admin access required", 403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try protected routes
|
// Try protected routes
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const filePath = join(assetsDir, fileName);
|
const filePath = join(assetsDir, fileName);
|
||||||
await Bun.write(filePath, buffer);
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
const assetUrl = `/assets/items/${fileName}`;
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
await itemsService.updateItem(item.id, {
|
await itemsService.updateItem(item.id, {
|
||||||
iconUrl: assetUrl,
|
iconUrl: assetUrl,
|
||||||
imageUrl: assetUrl,
|
imageUrl: assetUrl,
|
||||||
@@ -352,7 +352,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const filePath = join(assetsDir, fileName);
|
const filePath = join(assetsDir, fileName);
|
||||||
await Bun.write(filePath, buffer);
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
const assetUrl = `/assets/items/${fileName}`;
|
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||||
const updatedItem = await itemsService.updateItem(id, {
|
const updatedItem = await itemsService.updateItem(id, {
|
||||||
iconUrl: assetUrl,
|
iconUrl: assetUrl,
|
||||||
imageUrl: assetUrl,
|
imageUrl: assetUrl,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
*/
|
*/
|
||||||
if (pathname === "/api/lootdrops" && method === "POST") {
|
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
const { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
const { TextChannel } = await import("discord.js");
|
const { TextChannel } = await import("discord.js");
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
await spawnLootdrop(channel, data.amount, data.currency);
|
||||||
|
|
||||||
return jsonResponse({ success: true }, 201);
|
return jsonResponse({ success: true }, 201);
|
||||||
}, "spawn lootdrop");
|
}, "spawn lootdrop");
|
||||||
@@ -110,8 +110,8 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
if (!messageId) return null;
|
if (!messageId) return null;
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
const { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||||
const success = await lootdropService.deleteLootdrop(messageId);
|
const success = await deleteLootdrop(messageId);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return errorResponse("Lootdrop not found", 404);
|
return errorResponse("Lootdrop not found", 404);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const UpdateUserSchema = z.object({
|
|||||||
dailyStreak: z.coerce.number().int().min(0).optional(),
|
dailyStreak: z.coerce.number().int().min(0).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
classId: z.union([z.string(), z.number()]).optional(),
|
classId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
140
api/src/routes/users.routes.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
|
||||||
|
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
const getUserById = mock(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
username: id === "123" ? "player-one" : "user",
|
||||||
|
level: 5,
|
||||||
|
xp: 100n,
|
||||||
|
balance: 250n,
|
||||||
|
className: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
|
||||||
|
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
|
||||||
|
const removeItem = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("./auth.routes", () => ({
|
||||||
|
getSession: () => currentSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/modules/user/user.service", () => ({
|
||||||
|
userService: {
|
||||||
|
getUserById,
|
||||||
|
updateUser,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||||
|
inventoryService: {
|
||||||
|
getInventory,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: () => { },
|
||||||
|
info: () => { },
|
||||||
|
warn: () => { },
|
||||||
|
debug: () => { },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usersRoutes } from "./users.routes";
|
||||||
|
|
||||||
|
describe("Users Routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "123",
|
||||||
|
username: "player",
|
||||||
|
role: "player",
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
getUserById.mockClear();
|
||||||
|
updateUser.mockClear();
|
||||||
|
getInventory.mockClear();
|
||||||
|
addItem.mockClear();
|
||||||
|
removeItem.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the authenticated user through /api/me", async () => {
|
||||||
|
const url = new URL("http://localhost/api/me");
|
||||||
|
const res = await usersRoutes.handler({
|
||||||
|
req: new Request(url, { method: "GET" }),
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/api/me",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res?.status).toBe(200);
|
||||||
|
expect(getUserById).toHaveBeenCalledWith("123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
|
||||||
|
const url = new URL("http://localhost/api/me/inventory");
|
||||||
|
const res = await usersRoutes.handler({
|
||||||
|
req: new Request(url, { method: "GET" }),
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/api/me/inventory",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res?.status).toBe(200);
|
||||||
|
expect(getInventory).toHaveBeenCalledWith("123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates user updates before calling the service", async () => {
|
||||||
|
const url = new URL("http://localhost/api/users/123");
|
||||||
|
const res = await usersRoutes.handler({
|
||||||
|
req: new Request(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ level: -1 }),
|
||||||
|
}),
|
||||||
|
url,
|
||||||
|
method: "PUT",
|
||||||
|
pathname: "/api/users/123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res?.status).toBe(400);
|
||||||
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates inventory additions before calling the service", async () => {
|
||||||
|
const url = new URL("http://localhost/api/users/123/inventory");
|
||||||
|
const res = await usersRoutes.handler({
|
||||||
|
req: new Request(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ itemId: 1, quantity: 0 }),
|
||||||
|
}),
|
||||||
|
url,
|
||||||
|
method: "POST",
|
||||||
|
pathname: "/api/users/123/inventory",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res?.status).toBe(400);
|
||||||
|
expect(addItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates inventory removal query params before calling the service", async () => {
|
||||||
|
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
|
||||||
|
const res = await usersRoutes.handler({
|
||||||
|
req: new Request(url, { method: "DELETE" }),
|
||||||
|
url,
|
||||||
|
method: "DELETE",
|
||||||
|
pathname: "/api/users/123/inventory/1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res?.status).toBe(400);
|
||||||
|
expect(removeItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,16 +8,19 @@ import {
|
|||||||
jsonResponse,
|
jsonResponse,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
parseBody,
|
parseBody,
|
||||||
parseIdFromPath,
|
parseQuery,
|
||||||
parseStringIdFromPath,
|
parseStringIdFromPath,
|
||||||
withErrorHandling
|
withErrorHandling
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
|
import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas";
|
||||||
|
import { getSession } from "./auth.routes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users routes handler.
|
* Users routes handler.
|
||||||
*
|
*
|
||||||
* Endpoints:
|
* Endpoints:
|
||||||
|
* - GET /api/me - Get current authenticated user
|
||||||
|
* - GET /api/me/inventory - Get current authenticated user's inventory
|
||||||
* - GET /api/users - List users with filters
|
* - GET /api/users - List users with filters
|
||||||
* - GET /api/users/:id - Get single user
|
* - GET /api/users/:id - Get single user
|
||||||
* - PUT /api/users/:id - Update user
|
* - PUT /api/users/:id - Update user
|
||||||
@@ -30,6 +33,37 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
// Only handle requests to /api/users*
|
// Only handle requests to /api/users*
|
||||||
if (!pathname.startsWith("/api/users")) {
|
if (!pathname.startsWith("/api/users")) {
|
||||||
|
if (pathname === "/api/me" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const session = getSession(req);
|
||||||
|
if (!session) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
|
const user = await userService.getUserById(session.discordId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(user);
|
||||||
|
}, "fetch current user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/me/inventory" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const session = getSession(req);
|
||||||
|
if (!session) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const inventory = await inventoryService.getInventory(session.discordId);
|
||||||
|
return jsonResponse({ inventory });
|
||||||
|
}, "fetch current user inventory");
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +89,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const { users } = await import("@shared/db/schema");
|
const { users } = await import("@shared/db/schema");
|
||||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
||||||
|
const queryParams = parseQuery(url, UserQuerySchema);
|
||||||
|
if (queryParams instanceof Response) {
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
const search = url.searchParams.get("search") || undefined;
|
const { search, sortBy, sortOrder, limit, offset } = queryParams;
|
||||||
const sortBy = url.searchParams.get("sortBy") || "balance";
|
|
||||||
const sortOrder = url.searchParams.get("sortOrder") || "desc";
|
|
||||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
|
||||||
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
|
||||||
|
|
||||||
let query = DrizzleClient.select().from(users);
|
let query = DrizzleClient.select().from(users);
|
||||||
|
|
||||||
@@ -146,7 +180,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { userService } = await import("@shared/modules/user/user.service");
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
const data = await req.json() as Record<string, any>;
|
const parsed = await parseBody(req, UpdateUserSchema);
|
||||||
|
if (parsed instanceof Response) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await userService.getUserById(id);
|
const existing = await userService.getUserById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -155,14 +192,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
// Build update data (only allow safe fields)
|
// Build update data (only allow safe fields)
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (data.username !== undefined) updateData.username = data.username;
|
if (parsed.username !== undefined) updateData.username = parsed.username;
|
||||||
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance);
|
||||||
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
|
if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp);
|
||||||
if (data.level !== undefined) updateData.level = parseInt(data.level);
|
if (parsed.level !== undefined) updateData.level = parsed.level;
|
||||||
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
|
if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak;
|
||||||
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
|
if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive;
|
||||||
if (data.settings !== undefined) updateData.settings = data.settings;
|
if (parsed.settings !== undefined) updateData.settings = parsed.settings;
|
||||||
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
|
if (parsed.classId !== undefined) {
|
||||||
|
updateData.classId = parsed.classId === null ? null : BigInt(parsed.classId);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUser = await userService.updateUser(id, updateData);
|
const updatedUser = await userService.updateUser(id, updateData);
|
||||||
return jsonResponse({ success: true, user: updatedUser });
|
return jsonResponse({ success: true, user: updatedUser });
|
||||||
@@ -215,13 +254,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
const data = await req.json() as Record<string, any>;
|
const parsed = await parseBody(req, InventoryAddSchema);
|
||||||
|
if (parsed instanceof Response) {
|
||||||
if (!data.itemId || !data.quantity) {
|
return parsed;
|
||||||
return errorResponse("Missing required fields: itemId, quantity", 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
|
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.quantity));
|
||||||
return jsonResponse({ success: true, entry }, 201);
|
return jsonResponse({ success: true, entry }, 201);
|
||||||
}, "add item to inventory");
|
}, "add item to inventory");
|
||||||
}
|
}
|
||||||
@@ -245,11 +283,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const queryParams = parseQuery(url, InventoryRemoveQuerySchema);
|
||||||
|
if (queryParams instanceof Response) {
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
const amount = url.searchParams.get("amount");
|
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
|
||||||
const quantity = amount ? BigInt(amount) : 1n;
|
|
||||||
|
|
||||||
await inventoryService.removeItem(userId, itemId, quantity);
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}, "remove item from inventory");
|
}, "remove item from inventory");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,7 @@ mock.module("@shared/lib/utils", () => ({
|
|||||||
// --- Mock Auth (bypass authentication) ---
|
// --- Mock Auth (bypass authentication) ---
|
||||||
mock.module("./routes/auth.routes", () => ({
|
mock.module("./routes/auth.routes", () => ({
|
||||||
authRoutes: { name: "auth", handler: () => null },
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
isAuthenticated: () => true,
|
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
|
||||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- Mock Logger ---
|
// --- Mock Logger ---
|
||||||
|
|||||||
@@ -113,8 +113,7 @@ mock.module("bun", () => {
|
|||||||
// Mock auth (bypass authentication)
|
// Mock auth (bypass authentication)
|
||||||
mock.module("./routes/auth.routes", () => ({
|
mock.module("./routes/auth.routes", () => ({
|
||||||
authRoutes: { name: "auth", handler: () => null },
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
isAuthenticated: () => true,
|
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
|
||||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import createWebServer after mocks
|
// Import createWebServer after mocks
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
|
||||||
import type { WebServerInstance } from "./server";
|
import type { WebServerInstance } from "./server";
|
||||||
|
|
||||||
interface MockBotStats {
|
interface MockBotStats {
|
||||||
@@ -66,11 +66,17 @@ mock.module("@shared/lib/config", () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 4. Mock auth (bypass authentication for testing)
|
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
|
||||||
|
discordId: "123",
|
||||||
|
username: "admin-user",
|
||||||
|
role: "admin",
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Mock auth with a mutable session so tests can exercise authz paths.
|
||||||
mock.module("./routes/auth.routes", () => ({
|
mock.module("./routes/auth.routes", () => ({
|
||||||
authRoutes: { name: "auth", handler: () => null },
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
isAuthenticated: () => true,
|
getSession: () => currentSession,
|
||||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||||
@@ -91,37 +97,55 @@ describe("WebServer Security & Limits", () => {
|
|||||||
const hostname = "127.0.0.1";
|
const hostname = "127.0.0.1";
|
||||||
let serverInstance: WebServerInstance | null = null;
|
let serverInstance: WebServerInstance | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "123",
|
||||||
|
username: "admin-user",
|
||||||
|
role: "admin",
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (serverInstance) {
|
if (serverInstance) {
|
||||||
await serverInstance.stop();
|
await serverInstance.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reject more than 10 concurrent WebSocket connections", async () => {
|
test("should reject unauthorized websocket requests", async () => {
|
||||||
serverInstance = await createWebServer({ port, hostname });
|
serverInstance = await createWebServer({ port, hostname });
|
||||||
const wsUrl = `ws://${hostname}:${port}/ws`;
|
currentSession = null;
|
||||||
const sockets: WebSocket[] = [];
|
|
||||||
|
|
||||||
try {
|
const response = await fetch(`http://${hostname}:${port}/ws`);
|
||||||
// Attempt to open 12 connections (limit is 10)
|
const body = await response.text();
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
sockets.push(ws);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give connections time to settle
|
expect(response.status).toBe(401);
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
expect(body).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
const pendingCount = serverInstance.server.pendingWebSockets;
|
test("should accept websocket requests for authenticated sessions", async () => {
|
||||||
expect(pendingCount).toBeLessThanOrEqual(10);
|
if (!serverInstance) {
|
||||||
} finally {
|
serverInstance = await createWebServer({ port, hostname });
|
||||||
sockets.forEach(s => {
|
|
||||||
if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
|
|
||||||
s.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
|
||||||
|
const opened = await new Promise<boolean>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(false), 1000);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(opened).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 for health check", async () => {
|
test("should return 200 for health check", async () => {
|
||||||
@@ -135,15 +159,30 @@ describe("WebServer Security & Limits", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Administrative Actions", () => {
|
describe("Administrative Actions", () => {
|
||||||
test("should allow administrative actions without token", async () => {
|
test("should allow administrative actions for admin sessions", async () => {
|
||||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
|
|
||||||
expect(response.status).not.toBe(401);
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should reject administrative actions for player sessions", async () => {
|
||||||
|
currentSession = {
|
||||||
|
discordId: "456",
|
||||||
|
username: "player-user",
|
||||||
|
role: "player",
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const data = await response.json() as { error: string };
|
||||||
|
expect(data.error).toBe("Admin access required");
|
||||||
|
});
|
||||||
|
|
||||||
test("should reject maintenance mode with invalid payload", async () => {
|
test("should reject maintenance mode with invalid payload", async () => {
|
||||||
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -2,43 +2,36 @@
|
|||||||
* @fileoverview API server factory module.
|
* @fileoverview API server factory module.
|
||||||
* Exports a function to create and start the API server.
|
* Exports a function to create and start the API server.
|
||||||
* This allows the server to be started in-process from the main application.
|
* This allows the server to be started in-process from the main application.
|
||||||
*
|
*
|
||||||
* Routes are organized into modular files in the ./routes directory.
|
* Routes are organized into modular files in the ./routes directory.
|
||||||
* Each route module handles its own validation, business logic, and responses.
|
* Each route module handles its own validation, business logic, and responses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { serve, file } from "bun";
|
import { serve, file } from "bun";
|
||||||
|
import type { ServerWebSocket } from "bun";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
import { handleRequest } from "./routes";
|
import { handleRequest } from "./routes";
|
||||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { gameServer } from "./games/GameServer";
|
||||||
|
import type { WsConnectionData } from "./games/GameServer";
|
||||||
|
import { getSession } from "./routes/auth.routes";
|
||||||
|
import { GameWsClientSchema } from "./games/types";
|
||||||
|
|
||||||
export interface WebServerConfig {
|
// Register game plugins
|
||||||
port?: number;
|
import { gameRegistry } from "@shared/games/registry";
|
||||||
hostname?: string;
|
import { chessPlugin } from "@shared/games/chess/chess.plugin";
|
||||||
}
|
import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin";
|
||||||
|
gameRegistry.register(chessPlugin);
|
||||||
|
gameRegistry.register(blackjackPlugin);
|
||||||
|
|
||||||
export interface WebServerInstance {
|
const WS_CONFIG = {
|
||||||
server: ReturnType<typeof serve>;
|
MAX_CONNECTIONS: 200,
|
||||||
stop: () => Promise<void>;
|
MAX_PAYLOAD_BYTES: 16384,
|
||||||
url: string;
|
IDLE_TIMEOUT_SECONDS: 60,
|
||||||
}
|
STATS_BROADCAST_INTERVAL_MS: 5000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and starts the API server.
|
|
||||||
*
|
|
||||||
* @param config - Server configuration options
|
|
||||||
* @param config.port - Port to listen on (default: 3000)
|
|
||||||
* @param config.hostname - Hostname to bind to (default: "localhost")
|
|
||||||
* @returns Promise resolving to server instance with stop() method
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
|
|
||||||
* console.log(`Server running at ${server.url}`);
|
|
||||||
*
|
|
||||||
* // To stop the server:
|
|
||||||
* await server.stop();
|
|
||||||
*/
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
@@ -52,6 +45,17 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
".woff2": "font/woff2",
|
".woff2": "font/woff2",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface WebServerConfig {
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebServerInstance {
|
||||||
|
server: ReturnType<typeof serve>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve static files from the panel dist directory.
|
* Serve static files from the panel dist directory.
|
||||||
* Falls back to index.html for SPA routing.
|
* Falls back to index.html for SPA routing.
|
||||||
@@ -90,61 +94,63 @@ async function servePanelStatic(pathname: string, distDir: string): Promise<Resp
|
|||||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||||
const { port = 3000, hostname = "localhost" } = config;
|
const { port = 3000, hostname = "localhost" } = config;
|
||||||
|
|
||||||
// Configuration constants
|
let activeConnections = 0;
|
||||||
const MAX_CONNECTIONS = 10;
|
|
||||||
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
|
||||||
const IDLE_TIMEOUT_SECONDS = 60;
|
|
||||||
|
|
||||||
// Interval for broadcasting stats to all connected WS clients
|
|
||||||
let statsBroadcastInterval: Timer | undefined;
|
let statsBroadcastInterval: Timer | undefined;
|
||||||
|
|
||||||
const server = serve({
|
const server = serve<WsConnectionData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// WebSocket upgrade handling
|
|
||||||
if (url.pathname === "/ws") {
|
if (url.pathname === "/ws") {
|
||||||
const currentConnections = server.pendingWebSockets;
|
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
|
||||||
if (currentConnections >= MAX_CONNECTIONS) {
|
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
|
||||||
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
|
||||||
return new Response("Connection limit reached", { status: 429 });
|
return new Response("Connection limit reached", { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = server.upgrade(req);
|
const session = getSession(req);
|
||||||
|
if (!session) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = server.upgrade(req, {
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
discordId: session.discordId,
|
||||||
|
username: session.username,
|
||||||
|
role: session.role,
|
||||||
|
},
|
||||||
|
rooms: new Set<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
if (success) return undefined;
|
if (success) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to modular route handlers
|
|
||||||
const response = await handleRequest(req, url);
|
const response = await handleRequest(req, url);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
// Serve panel static files (production)
|
|
||||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||||
if (staticResponse) return staticResponse;
|
if (staticResponse) return staticResponse;
|
||||||
|
|
||||||
// No matching route found
|
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
},
|
},
|
||||||
|
|
||||||
websocket: {
|
websocket: {
|
||||||
/**
|
open(ws: ServerWebSocket<WsConnectionData>) {
|
||||||
* Called when a WebSocket client connects.
|
activeConnections++;
|
||||||
* Subscribes the client to the dashboard channel and sends initial stats.
|
|
||||||
*/
|
|
||||||
open(ws) {
|
|
||||||
ws.subscribe("dashboard");
|
ws.subscribe("dashboard");
|
||||||
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
ws.subscribe("lobby");
|
||||||
|
logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`);
|
||||||
|
|
||||||
// Send initial stats
|
|
||||||
getFullDashboardStats().then(stats => {
|
getFullDashboardStats().then(stats => {
|
||||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start broadcast interval if this is the first client
|
gameServer.handleOpen(ws);
|
||||||
|
|
||||||
if (!statsBroadcastInterval) {
|
if (!statsBroadcastInterval) {
|
||||||
statsBroadcastInterval = setInterval(async () => {
|
statsBroadcastInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -153,61 +159,69 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("web", "Error in stats broadcast", error);
|
logger.error("web", "Error in stats broadcast", error);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
async message(ws: ServerWebSocket<WsConnectionData>, message) {
|
||||||
* Called when a WebSocket message is received.
|
|
||||||
* Handles PING/PONG heartbeat messages.
|
|
||||||
*/
|
|
||||||
async message(ws, message) {
|
|
||||||
try {
|
try {
|
||||||
const messageStr = message.toString();
|
const messageStr = message.toString();
|
||||||
|
|
||||||
// Defense-in-depth: redundant length check before parsing
|
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
|
||||||
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
|
||||||
logger.error("web", "Payload exceeded maximum limit");
|
logger.error("web", "Payload exceeded maximum limit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData = JSON.parse(messageStr);
|
const rawData = JSON.parse(messageStr);
|
||||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
|
||||||
const parsed = WsMessageSchema.safeParse(rawData);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
// Handle dashboard-level messages (PING, etc.)
|
||||||
logger.error("web", "Invalid message format", parsed.error.issues);
|
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
|
||||||
|
ws.send(JSON.stringify({ type: "PONG" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.type === "PING") {
|
// Route game messages — try to parse as a game client message
|
||||||
ws.send(JSON.stringify({ type: "PONG" }));
|
const gameCheck = GameWsClientSchema.safeParse(rawData);
|
||||||
|
if (gameCheck.success) {
|
||||||
|
gameServer.handleMessage(ws, rawData).catch(err =>
|
||||||
|
logger.error("web", `Game message handler error: ${err}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||||
|
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||||
|
const parsed = WsMessageSchema.safeParse(rawData);
|
||||||
|
if (!parsed?.success) {
|
||||||
|
logger.error("web", "Invalid message format", parsed?.error.issues);
|
||||||
|
}
|
||||||
|
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("web", "Failed to handle message", e);
|
logger.error("web", "Failed to handle message", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
close(ws: ServerWebSocket<WsConnectionData>) {
|
||||||
* Called when a WebSocket client disconnects.
|
activeConnections--;
|
||||||
* Stops the broadcast interval if no clients remain.
|
|
||||||
*/
|
|
||||||
close(ws) {
|
|
||||||
ws.unsubscribe("dashboard");
|
ws.unsubscribe("dashboard");
|
||||||
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
ws.unsubscribe("lobby");
|
||||||
|
logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`);
|
||||||
|
|
||||||
// Stop broadcast interval if no clients left
|
gameServer.handleClose(ws);
|
||||||
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
|
||||||
|
if (activeConnections === 0 && statsBroadcastInterval) {
|
||||||
clearInterval(statsBroadcastInterval);
|
clearInterval(statsBroadcastInterval);
|
||||||
statsBroadcastInterval = undefined;
|
statsBroadcastInterval = undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
|
||||||
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for real-time events from the system bus
|
// Wire gameServer to Bun server for pub/sub publishing
|
||||||
|
gameServer.setServer(server);
|
||||||
|
|
||||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||||
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
||||||
@@ -226,18 +240,3 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the web server from the main application root.
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*
|
|
||||||
* @param webProjectPath - Deprecated, no longer used
|
|
||||||
* @param config - Server configuration options
|
|
||||||
* @returns Promise resolving to server instance
|
|
||||||
*/
|
|
||||||
export async function startWebServerFromRoot(
|
|
||||||
webProjectPath: string,
|
|
||||||
config: WebServerConfig = {}
|
|
||||||
): Promise<WebServerInstance> {
|
|
||||||
return createWebServer(config);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { pruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -83,7 +84,7 @@ export const prune = createCommand({
|
|||||||
time: 30000
|
time: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmation.customId === "cancel_prune") {
|
if (confirmation.customId === PRUNE_CUSTOM_IDS.CANCEL) {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
embeds: [getCancelledEmbed()],
|
embeds: [getCancelledEmbed()],
|
||||||
components: []
|
components: []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "@modules/system/terminal.service";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,83 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
import {
|
||||||
|
getInventoryListMessage,
|
||||||
|
getEmptyInventoryMessage,
|
||||||
|
getItemDetailMessage,
|
||||||
|
getDiscardConfirmMessage,
|
||||||
|
appendUseBackButton,
|
||||||
|
sortInventoryItems,
|
||||||
|
ITEMS_PER_PAGE,
|
||||||
|
type InventoryEntry,
|
||||||
|
} from "@/modules/inventory/inventory.view";
|
||||||
|
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||||
|
import {
|
||||||
|
parseInventoryCustomId,
|
||||||
|
executeItemUse,
|
||||||
|
} from "@/modules/inventory/inventory.interaction";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const inventory = createCommand({
|
export const inventory = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("inventory")
|
.setName("inventory")
|
||||||
.setDescription("View your or another user's inventory")
|
.setDescription("View your or another user's inventory")
|
||||||
.addUserOption(option =>
|
.addSubcommand(sub =>
|
||||||
option.setName("user")
|
sub.setName("list")
|
||||||
.setDescription("User to view")
|
.setDescription("View your or another user's inventory")
|
||||||
.setRequired(false)
|
.addUserOption(option =>
|
||||||
|
option.setName("user")
|
||||||
|
.setDescription("User to view")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("view")
|
||||||
|
.setDescription("View details of a specific item")
|
||||||
|
.addNumberOption(option =>
|
||||||
|
option.setName("item")
|
||||||
|
.setDescription("The item to view")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const viewerId = interaction.user.id;
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "view") {
|
||||||
|
// Direct item detail view
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await inventoryService.getInventory(user.id.toString());
|
||||||
|
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||||
|
if (!entry) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = user.id.toString();
|
||||||
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = itemId;
|
||||||
|
|
||||||
|
const response = await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "list" subcommand
|
||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||||
|
|
||||||
if (targetUser.bot) {
|
if (targetUser.bot) {
|
||||||
@@ -30,15 +91,232 @@ export const inventory = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await inventoryService.getInventory(user.id.toString());
|
const ownerId = user.id.toString();
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = getInventoryEmbed(items, user.username);
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = null;
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
const response = await interaction.editReply(
|
||||||
}
|
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
},
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
|
await interaction.respond(results);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupCollector(
|
||||||
|
interaction: any,
|
||||||
|
response: any,
|
||||||
|
viewerId: string,
|
||||||
|
ownerId: string,
|
||||||
|
username: string,
|
||||||
|
initialPage: number,
|
||||||
|
initialItemId: number | null,
|
||||||
|
) {
|
||||||
|
let currentPage = initialPage;
|
||||||
|
let selectedItemId = initialItemId;
|
||||||
|
|
||||||
|
const collector = response.createMessageComponentCollector({
|
||||||
|
time: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("collect", async (i: any) => {
|
||||||
|
if (i.user.id !== viewerId) return;
|
||||||
|
|
||||||
|
const parsed = parseInventoryCustomId(i.customId);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
// Re-fetch inventory for fresh data
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||||
|
|
||||||
|
switch (parsed.action) {
|
||||||
|
case "select": {
|
||||||
|
const itemId = parseInt(i.values[0]);
|
||||||
|
const entry = sorted.find(e => e.item.id === itemId);
|
||||||
|
if (!entry) break;
|
||||||
|
selectedItemId = itemId;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "prev": {
|
||||||
|
currentPage = Math.max(0, currentPage - 1);
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "next": {
|
||||||
|
currentPage = currentPage + 1;
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "back": {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "use": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||||
|
const message = getLootboxResultMessage(result.results, result.item);
|
||||||
|
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "use_back": {
|
||||||
|
// Return from use result to detail or list
|
||||||
|
if (!selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (entry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getDiscardConfirmMessage(entry, viewerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_confirm": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||||
|
|
||||||
|
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||||
|
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||||
|
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||||
|
|
||||||
|
if (freshEntry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (freshSorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_cancel": {
|
||||||
|
if (!selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Inventory interaction error:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async () => {
|
||||||
|
try {
|
||||||
|
// Re-render current view as static (no interactive components)
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||||
|
|
||||||
|
if (selectedItemId) {
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (entry) {
|
||||||
|
// Show detail view without action buttons
|
||||||
|
const msg = getItemDetailMessage(entry, viewerId, ownerId);
|
||||||
|
// Replace components with empty to remove buttons but keep container content
|
||||||
|
await interaction.editReply(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If re-rendering fails, at least try to clear gracefully
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js";
|
|||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
@@ -57,9 +57,8 @@ export const use = createCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
const message = getLootboxResultMessage(result.results, result.item);
|
||||||
|
await interaction.editReply(message as any);
|
||||||
await interaction.editReply({ embeds: [embed], files });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { questService } from "@shared/modules/quest/quest.service";
|
import { questService } from "@shared/modules/quest/quest.service";
|
||||||
import { createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import {
|
import {
|
||||||
getQuestListComponents,
|
getQuestListComponents,
|
||||||
getAvailableQuestsComponents,
|
getAvailableQuestsComponents,
|
||||||
getQuestActionRows
|
getQuestActionRows
|
||||||
} from "@/modules/quest/quest.view";
|
} from "@/modules/quest/quest.view";
|
||||||
|
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,16 +17,24 @@ export const quests = createCommand({
|
|||||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
|
let currentView: 'active' | 'available' = 'active';
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
|
||||||
|
currentView = viewType;
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
const updateView = async (viewType: 'active' | 'available') => {
|
|
||||||
const userQuests = await questService.getUserQuests(userId);
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
const availableQuests = await questService.getAvailableQuests(userId);
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
|
|
||||||
const containers = viewType === 'active'
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
? getQuestListComponents(userQuests)
|
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||||
: getAvailableQuestsComponents(availableQuests);
|
|
||||||
|
|
||||||
const actionRows = getQuestActionRows(viewType);
|
const containers = viewType === 'active'
|
||||||
|
? getQuestListComponents(userQuests, page)
|
||||||
|
: getAvailableQuestsComponents(availableQuests, page);
|
||||||
|
|
||||||
|
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: null,
|
content: null,
|
||||||
@@ -48,13 +57,19 @@ export const quests = createCommand({
|
|||||||
if (i.user.id !== interaction.user.id) return;
|
if (i.user.id !== interaction.user.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (i.customId === "quest_view_active") {
|
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('active');
|
await updateView('active', 0);
|
||||||
} else if (i.customId === "quest_view_available") {
|
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('available');
|
await updateView('available', 0);
|
||||||
} else if (i.customId.startsWith("quest_accept:")) {
|
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView(currentView, Math.max(0, currentPage - 1));
|
||||||
|
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView(currentView, currentPage + 1);
|
||||||
|
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
|
||||||
const questIdStr = i.customId.split(":")[1];
|
const questIdStr = i.customId.split(":")[1];
|
||||||
if (!questIdStr) return;
|
if (!questIdStr) return;
|
||||||
const questId = parseInt(questIdStr);
|
const questId = parseInt(questIdStr);
|
||||||
@@ -65,7 +80,8 @@ export const quests = createCommand({
|
|||||||
flags: MessageFlags.Ephemeral
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateView('active');
|
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||||
|
await updateView(currentView, currentPage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Quest interaction error:", error);
|
console.error("Quest interaction error:", error);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
|||||||
levelingService.processChatXp(message.author.id);
|
levelingService.processChatXp(message.author.id);
|
||||||
|
|
||||||
// Activity Tracking for Lootdrops
|
// Activity Tracking for Lootdrops
|
||||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
13
bot/index.ts
@@ -1,13 +1,15 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
import { join } from "node:path";
|
|
||||||
import { initializeConfig } from "@shared/lib/config";
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
|
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||||
import { startWebServerFromRoot } from "../api/src/server";
|
import { createWebServer } from "../api/src/server";
|
||||||
|
|
||||||
// Initialize config from database
|
// Initialize config from database
|
||||||
await initializeConfig();
|
await initializeConfig();
|
||||||
|
|
||||||
|
// Register domain event listeners before loading commands/events
|
||||||
|
registerDomainEventListeners();
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
@@ -18,12 +20,11 @@ console.log("🌐 Starting web server...");
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const webProjectPath = join(import.meta.dir, "../api");
|
|
||||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
const webHost = process.env.HOST || "0.0.0.0";
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
// Start web server in the same process
|
// Start web server in the same process
|
||||||
const webServer = await startWebServerFromRoot(webProjectPath, {
|
const webServer = await createWebServer({
|
||||||
port: webPort,
|
port: webPort,
|
||||||
hostname: webHost,
|
hostname: webHost,
|
||||||
});
|
});
|
||||||
@@ -50,4 +51,4 @@ const shutdownHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", shutdownHandler);
|
process.on("SIGINT", shutdownHandler);
|
||||||
process.on("SIGTERM", shutdownHandler);
|
process.on("SIGTERM", shutdownHandler);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||||
|
import { TRADE_CUSTOM_IDS } from "@modules/trade/trade.types";
|
||||||
|
import { SHOP_CUSTOM_IDS, LOOTDROP_CUSTOM_IDS } from "@modules/economy/economy.types";
|
||||||
|
import { ITEM_WIZARD_CUSTOM_IDS } from "@modules/admin/item_wizard.types";
|
||||||
|
import { TRIVIA_CUSTOM_IDS } from "@modules/trivia/trivia.types";
|
||||||
|
import { ENROLLMENT_CUSTOM_IDS } from "@modules/user/user.types";
|
||||||
|
import { FEEDBACK_CUSTOM_IDS } from "@modules/feedback/feedback.types";
|
||||||
|
|
||||||
// Union type for all component interactions
|
// Union type for all component interactions
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
// Type for the handler function that modules export
|
|
||||||
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
|
||||||
|
|
||||||
// Type for the dynamically imported module containing the handler
|
// Type for the dynamically imported module containing the handler
|
||||||
interface InteractionModule {
|
interface InteractionModule {
|
||||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||||
@@ -21,45 +24,45 @@ interface InteractionRoute {
|
|||||||
export const interactionRoutes: InteractionRoute[] = [
|
export const interactionRoutes: InteractionRoute[] = [
|
||||||
// --- TRADE MODULE ---
|
// --- TRADE MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
predicate: (i) => i.customId.startsWith(TRADE_CUSTOM_IDS.PREFIX) || i.customId === TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD,
|
||||||
handler: () => import("@/modules/trade/trade.interaction"),
|
handler: () => import("@/modules/trade/trade.interaction"),
|
||||||
method: 'handleTradeInteraction'
|
method: 'handleTradeInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- ECONOMY MODULE ---
|
// --- ECONOMY MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX),
|
||||||
handler: () => import("@/modules/economy/shop.interaction"),
|
handler: () => import("@/modules/economy/shop.interaction"),
|
||||||
method: 'handleShopInteraction'
|
method: 'handleShopInteraction'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith(LOOTDROP_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith(TRIVIA_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
method: 'handleTriviaInteraction'
|
method: 'handleTriviaInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- ADMIN MODULE ---
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/admin/item_wizard"),
|
handler: () => import("@/modules/admin/item_wizard"),
|
||||||
method: 'handleItemWizardInteraction'
|
method: 'handleItemWizardInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- USER MODULE ---
|
// --- USER MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
predicate: (i) => i.isButton() && i.customId === ENROLLMENT_CUSTOM_IDS.ENROLL,
|
||||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||||
method: 'handleEnrollmentInteraction'
|
method: 'handleEnrollmentInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- FEEDBACK MODULE ---
|
// --- FEEDBACK MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
|
||||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||||
method: 'handleFeedbackInteraction'
|
method: 'handleFeedbackInteraction'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { items } from "@db/schema";
|
|||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||||
// Only handle createitem interactions
|
// Only handle createitem interactions
|
||||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||||
if (!interaction.customId.startsWith("createitem_")) return;
|
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
|
||||||
|
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
let draft = draftSession.get(userId);
|
let draft = draftSession.get(userId);
|
||||||
|
|
||||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||||
if (interaction.customId === "createitem_cancel") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
|
||||||
draftSession.delete(userId);
|
draftSession.delete(userId);
|
||||||
if (interaction.isMessageComponent()) {
|
if (interaction.isMessageComponent()) {
|
||||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||||
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
if (!draft) {
|
if (!draft) {
|
||||||
if (interaction.isMessageComponent()) {
|
if (interaction.isMessageComponent()) {
|
||||||
// Create one implicitly to prevent crashes, or warn user
|
// Create one implicitly to prevent crashes, or warn user
|
||||||
if (interaction.customId === "createitem_start") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
|
||||||
// Allow start
|
// Allow start
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||||
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
// --- Routing ---
|
// --- Routing ---
|
||||||
|
|
||||||
// 1. Details Modal
|
// 1. Details Modal
|
||||||
if (interaction.customId === "createitem_details") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getDetailsModal(draft);
|
const modal = getDetailsModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Economy Modal
|
// 2. Economy Modal
|
||||||
if (interaction.customId === "createitem_economy") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getEconomyModal(draft);
|
const modal = getEconomyModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Visuals Modal
|
// 3. Visuals Modal
|
||||||
if (interaction.customId === "createitem_visuals") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = getVisualsModal(draft);
|
const modal = getVisualsModal(draft);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Type Toggle (Start Select Menu)
|
// 4. Type Toggle (Start Select Menu)
|
||||||
if (interaction.customId === "createitem_type_toggle") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const { components } = getItemTypeSelection();
|
const { components } = getItemTypeSelection();
|
||||||
await interaction.update({ components }); // Temporary view
|
await interaction.update({ components }); // Temporary view
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "createitem_select_type") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
|
||||||
if (!interaction.isStringSelectMenu()) return;
|
if (!interaction.isStringSelectMenu()) return;
|
||||||
const selected = interaction.values[0];
|
const selected = interaction.values[0];
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Add Effect Flow
|
// 5. Add Effect Flow
|
||||||
if (interaction.customId === "createitem_addeffect_start") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const { components } = getEffectTypeSelection();
|
const { components } = getEffectTypeSelection();
|
||||||
await interaction.update({ components });
|
await interaction.update({ components });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "createitem_select_effect_type") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
|
||||||
if (!interaction.isStringSelectMenu()) return;
|
if (!interaction.isStringSelectMenu()) return;
|
||||||
const effectType = interaction.values[0];
|
const effectType = interaction.values[0];
|
||||||
if (!effectType) return;
|
if (!effectType) return;
|
||||||
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toggle Consume
|
// Toggle Consume
|
||||||
if (interaction.customId === "createitem_toggle_consume") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
draft.usageData.consume = !draft.usageData.consume;
|
draft.usageData.consume = !draft.usageData.consume;
|
||||||
const payload = renderWizard(userId);
|
const payload = renderWizard(userId);
|
||||||
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
|
|
||||||
// 6. Handle Modal Submits
|
// 6. Handle Modal Submits
|
||||||
if (interaction.isModalSubmit()) {
|
if (interaction.isModalSubmit()) {
|
||||||
if (interaction.customId === "createitem_modal_details") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
|
||||||
draft.name = interaction.fields.getTextInputValue("name");
|
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
|
||||||
draft.description = interaction.fields.getTextInputValue("desc");
|
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
|
||||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
|
||||||
}
|
}
|
||||||
else if (interaction.customId === "createitem_modal_economy") {
|
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
|
||||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
|
||||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||||
}
|
}
|
||||||
else if (interaction.customId === "createitem_modal_visuals") {
|
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
|
||||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
|
||||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
|
||||||
}
|
}
|
||||||
else if (interaction.customId === "createitem_modal_effect") {
|
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
|
||||||
const type = draft.pendingEffectType;
|
const type = draft.pendingEffectType;
|
||||||
if (type) {
|
if (type) {
|
||||||
let effect: ItemEffect | null = null;
|
let effect: ItemEffect | null = null;
|
||||||
|
|
||||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
|
||||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.REPLY_MESSAGE) {
|
else if (type === EffectType.REPLY_MESSAGE) {
|
||||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.XP_BOOST) {
|
else if (type === EffectType.XP_BOOST) {
|
||||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.TEMP_ROLE) {
|
else if (type === EffectType.TEMP_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === EffectType.COLOR_ROLE) {
|
else if (type === EffectType.COLOR_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Save
|
// 7. Save
|
||||||
if (interaction.customId === "createitem_save") {
|
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
|
|
||||||
await interaction.deferUpdate(); // Prepare to save
|
await interaction.deferUpdate(); // Prepare to save
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
|
||||||
|
export const ITEM_WIZARD_CUSTOM_IDS = {
|
||||||
|
PREFIX: "createitem_",
|
||||||
|
START: "createitem_start",
|
||||||
|
DETAILS: "createitem_details",
|
||||||
|
ECONOMY: "createitem_economy",
|
||||||
|
VISUALS: "createitem_visuals",
|
||||||
|
TYPE_TOGGLE: "createitem_type_toggle",
|
||||||
|
SELECT_TYPE: "createitem_select_type",
|
||||||
|
ADD_EFFECT_START: "createitem_addeffect_start",
|
||||||
|
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
|
||||||
|
TOGGLE_CONSUME: "createitem_toggle_consume",
|
||||||
|
SAVE: "createitem_save",
|
||||||
|
CANCEL: "createitem_cancel",
|
||||||
|
MODAL_DETAILS: "createitem_modal_details",
|
||||||
|
MODAL_ECONOMY: "createitem_modal_economy",
|
||||||
|
MODAL_VISUALS: "createitem_modal_visuals",
|
||||||
|
MODAL_EFFECT: "createitem_modal_effect",
|
||||||
|
// Modal field IDs
|
||||||
|
FIELD_NAME: "name",
|
||||||
|
FIELD_DESC: "desc",
|
||||||
|
FIELD_RARITY: "rarity",
|
||||||
|
FIELD_PRICE: "price",
|
||||||
|
FIELD_ICON: "icon",
|
||||||
|
FIELD_IMAGE: "image",
|
||||||
|
FIELD_AMOUNT: "amount",
|
||||||
|
FIELD_MESSAGE: "message",
|
||||||
|
FIELD_MULTIPLIER: "multiplier",
|
||||||
|
FIELD_DURATION: "duration",
|
||||||
|
FIELD_ROLE_ID: "role_id",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface DraftItem {
|
export interface DraftItem {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type MessageActionRowComponentBuilder
|
type MessageActionRowComponentBuilder
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||||
import { ItemType } from "@shared/lib/constants";
|
import { ItemType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
|||||||
// Components
|
// Components
|
||||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row1, row2] };
|
return { embeds: [embed], components: [row1, row2] };
|
||||||
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
|||||||
|
|
||||||
export const getItemTypeSelection = () => {
|
export const getItemTypeSelection = () => {
|
||||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||||
);
|
);
|
||||||
return { components: [row] };
|
return { components: [row] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEffectTypeSelection = () => {
|
export const getEffectTypeSelection = () => {
|
||||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||||
);
|
);
|
||||||
return { components: [row] };
|
return { components: [row] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDetailsModal = (current: DraftItem) => {
|
export const getDetailsModal = (current: DraftItem) => {
|
||||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEconomyModal = (current: DraftItem) => {
|
export const getEconomyModal = (current: DraftItem) => {
|
||||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVisualsModal = (current: DraftItem) => {
|
export const getVisualsModal = (current: DraftItem) => {
|
||||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEffectConfigModal = (effectType: string) => {
|
export const getEffectConfigModal = (effectType: string) => {
|
||||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
|
||||||
|
|
||||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||||
} else if (effectType === "REPLY_MESSAGE") {
|
} else if (effectType === "REPLY_MESSAGE") {
|
||||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||||
} else if (effectType === "XP_BOOST") {
|
} else if (effectType === "XP_BOOST") {
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||||
);
|
);
|
||||||
} else if (effectType === "TEMP_ROLE") {
|
} else if (effectType === "TEMP_ROLE") {
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||||
);
|
);
|
||||||
} else if (effectType === "COLOR_ROLE") {
|
} else if (effectType === "COLOR_ROLE") {
|
||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return modal;
|
return modal;
|
||||||
|
|||||||
10
bot/modules/economy/economy.types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const LOOTDROP_CUSTOM_IDS = {
|
||||||
|
PREFIX: "lootdrop_",
|
||||||
|
CLAIM: "lootdrop_claim",
|
||||||
|
CLAIM_DISABLED: "lootdrop_claim_disabled",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SHOP_CUSTOM_IDS = {
|
||||||
|
BUY_PREFIX: "shop_buy_",
|
||||||
|
BUY: (itemId: number) => `shop_buy_${itemId}`,
|
||||||
|
} as const;
|
||||||
60
bot/modules/economy/lootdrop.handler.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Message, TextChannel } from "discord.js";
|
||||||
|
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||||
|
import { getLootdropMessage } from "./lootdrop.view";
|
||||||
|
import { terminalService } from "@modules/system/terminal.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a Discord message for lootdrop activity tracking.
|
||||||
|
* Called from messageCreate event handler.
|
||||||
|
*/
|
||||||
|
export async function processLootdropMessage(message: Message): Promise<void> {
|
||||||
|
if (message.author.bot || !message.guild) return;
|
||||||
|
|
||||||
|
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
|
||||||
|
|
||||||
|
if (shouldSpawn) {
|
||||||
|
await spawnLootdrop(message.channel as TextChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a lootdrop in a Discord channel.
|
||||||
|
* Used by both bot events and API routes.
|
||||||
|
*/
|
||||||
|
export async function spawnLootdrop(
|
||||||
|
channel: TextChannel,
|
||||||
|
overrideReward?: number,
|
||||||
|
overrideCurrency?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
|
||||||
|
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sentMessage = await channel.send({ content, files, components });
|
||||||
|
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
|
||||||
|
terminalService.update(channel.guildId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to spawn lootdrop:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a lootdrop from DB and Discord.
|
||||||
|
*/
|
||||||
|
export async function deleteLootdrop(messageId: string): Promise<boolean> {
|
||||||
|
const result = await lootdropService.removeLootdrop(messageId);
|
||||||
|
if (!result) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { AuroraClient } = await import("@/lib/BotClient");
|
||||||
|
const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel;
|
||||||
|
if (channel) {
|
||||||
|
const message = await channel.messages.fetch(messageId);
|
||||||
|
if (message) await message.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not delete lootdrop message from Discord:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { ButtonInteraction } from "discord.js";
|
|||||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
|
import { terminalService } from "@modules/system/terminal.service";
|
||||||
|
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
if (interaction.customId === "lootdrop_claim") {
|
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||||
@@ -13,6 +15,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
|||||||
throw new UserError(result.error || "Failed to claim.");
|
throw new UserError(result.error || "Failed to claim.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update terminal display after successful claim
|
||||||
|
terminalService.update();
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||||
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||||
|
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function getLootdropMessage(reward: number, currency: string) {
|
export async function getLootdropMessage(reward: number, currency: string) {
|
||||||
const cardBuffer = await generateLootdropCard(reward, currency);
|
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||||
|
|
||||||
const claimButton = new ButtonBuilder()
|
const claimButton = new ButtonBuilder()
|
||||||
.setCustomId("lootdrop_claim")
|
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
|
||||||
.setLabel("CLAIM REWARD")
|
.setLabel("CLAIM REWARD")
|
||||||
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||||
.setEmoji("🌠");
|
.setEmoji("🌠");
|
||||||
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
|
|||||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("lootdrop_claim_disabled")
|
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
|
||||||
.setLabel("CLAIMED")
|
.setLabel("CLAIMED")
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
.setEmoji("✅")
|
.setEmoji("✅")
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
|
|||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||||
|
|
||||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
if (!interaction.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX)) return;
|
||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
const itemId = parseInt(interaction.customId.replace(SHOP_CUSTOM_IDS.BUY_PREFIX, ""));
|
||||||
if (isNaN(itemId)) {
|
if (isNaN(itemId)) {
|
||||||
throw new UserError("Invalid Item ID.");
|
throw new UserError("Invalid Item ID.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
ButtonBuilder,
|
ButtonBuilder,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
Colors,
|
|
||||||
ContainerBuilder,
|
ContainerBuilder,
|
||||||
SectionBuilder,
|
SectionBuilder,
|
||||||
TextDisplayBuilder,
|
TextDisplayBuilder,
|
||||||
@@ -19,27 +18,8 @@ import { join } from "path";
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { LootType, EffectType } from "@shared/lib/constants";
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||||
// Rarity Color Map
|
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||||
const RarityColors: Record<string, number> = {
|
|
||||||
"C": Colors.LightGrey,
|
|
||||||
"R": Colors.Blue,
|
|
||||||
"SR": Colors.Purple,
|
|
||||||
"SSR": Colors.Gold,
|
|
||||||
"CURRENCY": Colors.Green,
|
|
||||||
"XP": Colors.Aqua,
|
|
||||||
"NOTHING": Colors.DarkButNotBlack
|
|
||||||
};
|
|
||||||
|
|
||||||
const TitleMap: Record<string, string> = {
|
|
||||||
"C": "📦 Common Items",
|
|
||||||
"R": "📦 Rare Items",
|
|
||||||
"SR": "✨ Super Rare Items",
|
|
||||||
"SSR": "🌟 SSR Items",
|
|
||||||
"CURRENCY": "💰 Currency",
|
|
||||||
"XP": "🔮 Experience",
|
|
||||||
"NOTHING": "💨 Empty"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getShopListingMessage(
|
export function getShopListingMessage(
|
||||||
item: {
|
item: {
|
||||||
@@ -61,7 +41,7 @@ export function getShopListingMessage(
|
|||||||
|
|
||||||
// Handle local icon
|
// Handle local icon
|
||||||
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.iconUrl).replace(/^\/?assets\//, ""));
|
||||||
if (existsSync(iconPath)) {
|
if (existsSync(iconPath)) {
|
||||||
const iconName = defaultName(item.iconUrl);
|
const iconName = defaultName(item.iconUrl);
|
||||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
@@ -74,7 +54,7 @@ export function getShopListingMessage(
|
|||||||
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||||
displayImageUrl = thumbnailUrl;
|
displayImageUrl = thumbnailUrl;
|
||||||
} else {
|
} else {
|
||||||
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.imageUrl).replace(/^\/?assets\//, ""));
|
||||||
if (existsSync(imagePath)) {
|
if (existsSync(imagePath)) {
|
||||||
const imageName = defaultName(item.imageUrl);
|
const imageName = defaultName(item.imageUrl);
|
||||||
if (!files.find(f => f.name === imageName)) {
|
if (!files.find(f => f.name === imageName)) {
|
||||||
@@ -89,7 +69,7 @@ export function getShopListingMessage(
|
|||||||
|
|
||||||
// 1. Main Container
|
// 1. Main Container
|
||||||
const mainContainer = new ContainerBuilder()
|
const mainContainer = new ContainerBuilder()
|
||||||
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
.setAccentColor(getRarityConfig(item.rarity || "C").color);
|
||||||
|
|
||||||
// Header Section
|
// Header Section
|
||||||
const infoSection = new SectionBuilder()
|
const infoSection = new SectionBuilder()
|
||||||
@@ -119,82 +99,114 @@ export function getShopListingMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Loot Table (if applicable)
|
// Create buy button (used in either main or loot container)
|
||||||
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
|
||||||
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
|
||||||
const pool = lootboxEffect.pool as LootTableItem[];
|
|
||||||
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
|
|
||||||
|
|
||||||
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
||||||
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
|
|
||||||
|
|
||||||
const groups: Record<string, string[]> = {};
|
|
||||||
for (const drop of pool) {
|
|
||||||
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
|
|
||||||
let line = "";
|
|
||||||
let rarity = "C";
|
|
||||||
|
|
||||||
switch (drop.type as any) {
|
|
||||||
case LootType.CURRENCY:
|
|
||||||
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
|
|
||||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
|
||||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
|
||||||
line = `**${currAmount} 🪙** (${chance}%)`;
|
|
||||||
rarity = "CURRENCY";
|
|
||||||
break;
|
|
||||||
case LootType.XP:
|
|
||||||
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
|
|
||||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
|
||||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
|
||||||
line = `**${xpAmount} XP** (${chance}%)`;
|
|
||||||
rarity = "XP";
|
|
||||||
break;
|
|
||||||
case LootType.ITEM:
|
|
||||||
const referencedItems = context?.referencedItems;
|
|
||||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
|
||||||
const i = referencedItems.get(drop.itemId)!;
|
|
||||||
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
|
|
||||||
rarity = i.rarity;
|
|
||||||
} else {
|
|
||||||
line = `**Unknown Item** (${chance}%)`;
|
|
||||||
rarity = "C";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case LootType.NOTHING:
|
|
||||||
line = `**Nothing** (${chance}%)`;
|
|
||||||
rarity = "NOTHING";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line) {
|
|
||||||
if (!groups[rarity]) groups[rarity] = [];
|
|
||||||
groups[rarity]!.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
|
||||||
for (const rarity of order) {
|
|
||||||
if (groups[rarity] && groups[rarity]!.length > 0) {
|
|
||||||
mainContainer.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
|
|
||||||
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purchase Row
|
|
||||||
const buyButton = new ButtonBuilder()
|
const buyButton = new ButtonBuilder()
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
|
||||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("🛒");
|
.setEmoji("🛒");
|
||||||
|
|
||||||
mainContainer.addActionRowComponents(
|
// 2. Loot Table (if applicable) — separate Container with blurple accent
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
const lootboxEffect = item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
);
|
if (lootboxEffect) {
|
||||||
|
const pool = lootboxEffect.pool as LootTableItem[];
|
||||||
|
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
|
||||||
|
|
||||||
containers.push(mainContainer);
|
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
|
||||||
|
lootContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group drops by rarity tier with aggregated percentages
|
||||||
|
const tiers: Record<string, { items: string[]; totalChance: number }> = {};
|
||||||
|
|
||||||
|
for (const drop of pool) {
|
||||||
|
const chance = (drop.weight / totalWeight) * 100;
|
||||||
|
let line = "";
|
||||||
|
let rarity = "C";
|
||||||
|
|
||||||
|
switch (drop.type as any) {
|
||||||
|
case LootType.CURRENCY: {
|
||||||
|
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||||
|
line = `${amt} 🪙`;
|
||||||
|
rarity = "CURRENCY";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case LootType.XP: {
|
||||||
|
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||||
|
line = `${amt} XP`;
|
||||||
|
rarity = "XP";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case LootType.ITEM: {
|
||||||
|
const referencedItems = context?.referencedItems;
|
||||||
|
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||||
|
const i = referencedItems.get(drop.itemId)!;
|
||||||
|
line = `${i.name} ×${drop.amount || 1}`;
|
||||||
|
rarity = i.rarity;
|
||||||
|
} else {
|
||||||
|
line = `Unknown Item`;
|
||||||
|
rarity = "C";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case LootType.NOTHING: {
|
||||||
|
line = "Nothing";
|
||||||
|
rarity = "NOTHING";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
||||||
|
const tier = tiers[rarity]!;
|
||||||
|
tier.items.push(line);
|
||||||
|
tier.totalChance += chance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||||
|
let isFirst = true;
|
||||||
|
for (const rarity of order) {
|
||||||
|
const tier = tiers[rarity];
|
||||||
|
if (!tier || tier.items.length === 0) continue;
|
||||||
|
|
||||||
|
if (!isFirst) {
|
||||||
|
lootContainer.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
|
const config = getRarityConfig(rarity);
|
||||||
|
const chanceStr = tier.totalChance < 0.1 ? "<0.1" : tier.totalChance.toFixed(1);
|
||||||
|
lootContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`${config.emoji} **${config.label}** — ${chanceStr}%`
|
||||||
|
),
|
||||||
|
new TextDisplayBuilder().setContent(tier.items.join(", "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase button inside loot table container
|
||||||
|
lootContainer.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||||
|
);
|
||||||
|
|
||||||
|
containers.push(mainContainer);
|
||||||
|
containers.push(lootContainer);
|
||||||
|
} else {
|
||||||
|
// Non-lootbox items: purchase button stays in main container
|
||||||
|
mainContainer.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||||
|
);
|
||||||
|
|
||||||
|
containers.push(mainContainer);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
components: containers as any,
|
components: containers as any,
|
||||||
@@ -202,7 +214,3 @@ export function getShopListingMessage(
|
|||||||
flags: MessageFlags.IsComponentsV2
|
flags: MessageFlags.IsComponentsV2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultName(path: string): string {
|
|
||||||
return path.split("/").pop() || "image.png";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
|
|||||||
|
|
||||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
// Handle select menu for choosing feedback type
|
// Handle select menu for choosing feedback type
|
||||||
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
|
if (interaction.isStringSelectMenu() && interaction.customId === FEEDBACK_CUSTOM_IDS.SELECT_TYPE) {
|
||||||
const feedbackType = interaction.values[0] as FeedbackType;
|
const feedbackType = interaction.values[0] as FeedbackType;
|
||||||
|
|
||||||
if (!feedbackType) {
|
if (!feedbackType) {
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FEEDBACK_CUSTOM_IDS = {
|
export const FEEDBACK_CUSTOM_IDS = {
|
||||||
|
PREFIX: "feedback_",
|
||||||
|
SELECT_TYPE: "feedback_select_type",
|
||||||
MODAL: "feedback_modal",
|
MODAL: "feedback_modal",
|
||||||
TYPE_FIELD: "feedback_type",
|
TYPE_FIELD: "feedback_type",
|
||||||
TITLE_FIELD: "feedback_title",
|
TITLE_FIELD: "feedback_title",
|
||||||
DESCRIPTION_FIELD: "feedback_description"
|
DESCRIPTION_FIELD: "feedback_description",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
|
|||||||
|
|
||||||
export function getFeedbackTypeMenu() {
|
export function getFeedbackTypeMenu() {
|
||||||
const select = new StringSelectMenuBuilder()
|
const select = new StringSelectMenuBuilder()
|
||||||
.setCustomId("feedback_select_type")
|
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
|
||||||
.setPlaceholder("Choose feedback type")
|
.setPlaceholder("Choose feedback type")
|
||||||
.addOptions([
|
.addOptions([
|
||||||
{
|
{
|
||||||
|
|||||||
72
bot/modules/inventory/inventory.interaction.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { getLootboxResultMessage } from "./inventory.view";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||||
|
|
||||||
|
export interface InventoryState {
|
||||||
|
ownerId: string;
|
||||||
|
viewerId: string;
|
||||||
|
page: number;
|
||||||
|
selectedItemId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the viewer user ID from an inventory custom ID.
|
||||||
|
* Custom IDs follow the format: inv_{action}_{viewerId}
|
||||||
|
*/
|
||||||
|
export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null {
|
||||||
|
const match = customId.match(/^inv_(\w+?)_(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { action: match[1]!, viewerId: match[2]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a custom ID belongs to the inventory system.
|
||||||
|
*/
|
||||||
|
export function isInventoryInteraction(customId: string): boolean {
|
||||||
|
return customId.startsWith(INVENTORY_CUSTOM_IDS.PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Use" button — executes item effects.
|
||||||
|
* Returns the result messages array from inventoryService.useItem,
|
||||||
|
* plus handles role-based effects that require the guild member.
|
||||||
|
*/
|
||||||
|
export async function executeItemUse(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
userId: string,
|
||||||
|
itemId: number,
|
||||||
|
): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> {
|
||||||
|
const result = await inventoryService.useItem(userId, itemId);
|
||||||
|
|
||||||
|
// Handle role effects (same logic as /use command)
|
||||||
|
const usageData = result.usageData;
|
||||||
|
if (usageData) {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
|
for (const effect of usageData.effects) {
|
||||||
|
if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") {
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild?.members.fetch(userId);
|
||||||
|
if (member) {
|
||||||
|
if (effect.type === "TEMP_ROLE") {
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
} else if (effect.type === "COLOR_ROLE") {
|
||||||
|
const rolesToRemove = colorRoles.filter((r: string) => member.roles.cache.has(r));
|
||||||
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in inventory use:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
13
bot/modules/inventory/inventory.types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const INVENTORY_CUSTOM_IDS = {
|
||||||
|
PREFIX: "inv_",
|
||||||
|
SELECT: (viewerId: string) => `inv_select_${viewerId}`,
|
||||||
|
PREV: (viewerId: string) => `inv_prev_${viewerId}`,
|
||||||
|
PAGE: (viewerId: string) => `inv_page_${viewerId}`,
|
||||||
|
NEXT: (viewerId: string) => `inv_next_${viewerId}`,
|
||||||
|
BACK: (viewerId: string) => `inv_back_${viewerId}`,
|
||||||
|
USE: (viewerId: string) => `inv_use_${viewerId}`,
|
||||||
|
DISCARD: (viewerId: string) => `inv_discard_${viewerId}`,
|
||||||
|
DISCARD_CONFIRM: (viewerId: string) => `inv_discard_confirm_${viewerId}`,
|
||||||
|
DISCARD_CANCEL: (viewerId: string) => `inv_discard_cancel_${viewerId}`,
|
||||||
|
USE_BACK: (viewerId: string) => `inv_use_back_${viewerId}`,
|
||||||
|
} as const;
|
||||||
@@ -1,140 +1,488 @@
|
|||||||
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
import {
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
EmbedBuilder,
|
||||||
import { EffectType } from "@shared/lib/constants";
|
AttachmentBuilder,
|
||||||
|
ContainerBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuOptionBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
} from "discord.js";
|
||||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||||
|
import { ItemType } from "@shared/lib/constants";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
|
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||||
|
|
||||||
/**
|
export const ITEMS_PER_PAGE = 5;
|
||||||
* Inventory entry with item details
|
|
||||||
*/
|
const RARITY_SORT_ORDER: Record<string, number> = {
|
||||||
interface InventoryEntry {
|
SSR: 0,
|
||||||
|
SR: 1,
|
||||||
|
R: 2,
|
||||||
|
C: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rarity: string | null;
|
||||||
|
type: string;
|
||||||
|
price: bigint | null;
|
||||||
|
iconUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
usageData: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryEntry {
|
||||||
quantity: bigint | null;
|
quantity: bigint | null;
|
||||||
item: {
|
item: InventoryItem;
|
||||||
id: number;
|
}
|
||||||
name: string;
|
|
||||||
[key: string]: any;
|
export function sortInventoryItems(entries: InventoryEntry[]): InventoryEntry[] {
|
||||||
|
return [...entries].sort((a, b) => {
|
||||||
|
const rarityA = RARITY_SORT_ORDER[a.item.rarity ?? "C"] ?? 3;
|
||||||
|
const rarityB = RARITY_SORT_ORDER[b.item.rarity ?? "C"] ?? 3;
|
||||||
|
if (rarityA !== rarityB) return rarityA - rarityB;
|
||||||
|
return a.item.name.localeCompare(b.item.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInventoryListMessage(
|
||||||
|
entries: InventoryEntry[],
|
||||||
|
username: string,
|
||||||
|
page: number,
|
||||||
|
viewerId: string,
|
||||||
|
ownerId: string,
|
||||||
|
) {
|
||||||
|
const sorted = sortInventoryItems(entries);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const pageItems = sorted.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// Accent color from highest-rarity item on page
|
||||||
|
const highestRarity = pageItems[0]?.item.rarity ?? "C";
|
||||||
|
const accentColor = getRarityConfig(highestRarity).color;
|
||||||
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(accentColor)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
||||||
|
new TextDisplayBuilder().setContent(`-# ${sorted.length} item${sorted.length !== 1 ? "s" : ""} total`)
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Item rows
|
||||||
|
const lines = pageItems.map((entry) => {
|
||||||
|
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||||
|
return `${rc.squareEmoji} **${entry.item.name}** — ${rc.label} · ${entry.item.type} · ×${entry.quantity}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(lines.join("\n"))
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select menu with current page items
|
||||||
|
const selectMenu = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.SELECT(viewerId))
|
||||||
|
.setPlaceholder("Select an item for details");
|
||||||
|
|
||||||
|
for (const entry of pageItems) {
|
||||||
|
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||||
|
selectMenu.addOptions(
|
||||||
|
new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(entry.item.name)
|
||||||
|
.setDescription(`${rc.label} · ${entry.item.type}`)
|
||||||
|
.setValue(entry.item.id.toString())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pagination buttons
|
||||||
|
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.PREV(viewerId))
|
||||||
|
.setLabel("◀ Previous")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(safePage <= 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.PAGE(viewerId))
|
||||||
|
.setLabel(`Page ${safePage + 1}/${totalPages}`)
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(true),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.NEXT(viewerId))
|
||||||
|
.setLabel("Next ▶")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(safePage >= totalPages - 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addActionRowComponents(navRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: [container] as any,
|
||||||
|
files: [] as AttachmentBuilder[],
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmptyInventoryMessage(username: string) {
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(0x95A5A6)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
||||||
|
new TextDisplayBuilder().setContent("*No items yet. Visit the shop or complete quests to earn items!*")
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: [container] as any,
|
||||||
|
files: [],
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemDetailMessage(
|
||||||
|
entry: InventoryEntry,
|
||||||
|
viewerId: string,
|
||||||
|
ownerId: string,
|
||||||
|
) {
|
||||||
|
const { item } = entry;
|
||||||
|
const rc = getRarityConfig(item.rarity ?? "C");
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
|
||||||
|
const container = new ContainerBuilder().setAccentColor(rc.color);
|
||||||
|
|
||||||
|
// Header section with thumbnail
|
||||||
|
const section = new SectionBuilder().addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`${rc.squareEmoji} **${item.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(`-# ${rc.label} · ${item.type}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve icon thumbnail
|
||||||
|
const iconUrl = resolveItemUrl(item.iconUrl, files);
|
||||||
|
if (iconUrl) {
|
||||||
|
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(iconUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addSectionComponents(section);
|
||||||
|
|
||||||
|
// Artwork via MediaGallery
|
||||||
|
const imageUrl = resolveItemUrl(item.imageUrl, files);
|
||||||
|
if (imageUrl && item.imageUrl !== item.iconUrl) {
|
||||||
|
container.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
new MediaGalleryItemBuilder().setURL(imageUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (item.description) {
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(item.description)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats row
|
||||||
|
const priceText = item.price ? `${item.price} 🪙` : "Not purchasable";
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`Owned: **×${entry.quantity}** · Value: **${priceText}**`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const isOwner = viewerId === ownerId;
|
||||||
|
const usageData = item.usageData as ItemUsageData | null;
|
||||||
|
const isUsable = isOwner && item.type === ItemType.CONSUMABLE &&
|
||||||
|
usageData?.effects && usageData.effects.length > 0;
|
||||||
|
|
||||||
|
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.BACK(viewerId))
|
||||||
|
.setLabel("◀ Back")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUsable) {
|
||||||
|
actionRow.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.USE(viewerId))
|
||||||
|
.setLabel("🧪 Use")
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
actionRow.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD(viewerId))
|
||||||
|
.setLabel("🗑 Discard")
|
||||||
|
.setStyle(ButtonStyle.Danger)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addActionRowComponents(actionRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: [container] as any,
|
||||||
|
files,
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string) {
|
||||||
|
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||||
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(0xED4245)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`Are you sure you want to discard 1× **${entry.item.name}**?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CONFIRM(viewerId))
|
||||||
|
.setLabel("Confirm")
|
||||||
|
.setStyle(ButtonStyle.Danger),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CANCEL(viewerId))
|
||||||
|
.setLabel("Cancel")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: [container] as any,
|
||||||
|
files: [],
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an embed displaying a user's inventory
|
* Wraps a use-item result message with a Back button so the user
|
||||||
|
* can return to the inventory after seeing the effect result.
|
||||||
*/
|
*/
|
||||||
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
export function appendUseBackButton(message: any, viewerId: string): any {
|
||||||
const description = items.map(entry => {
|
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
new ButtonBuilder()
|
||||||
}).join("\n");
|
.setCustomId(INVENTORY_CUSTOM_IDS.USE_BACK(viewerId))
|
||||||
|
.setLabel("◀ Back to Inventory")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
|
||||||
return new EmbedBuilder()
|
// If CV2 message with components array, append to the first container
|
||||||
.setTitle(`📦 ${username}'s Inventory`)
|
if (message.components && message.flags === MessageFlags.IsComponentsV2) {
|
||||||
.setDescription(description)
|
const container = message.components[0];
|
||||||
.setColor(0x3498db); // Blue
|
if (container?.addActionRowComponents) {
|
||||||
|
container.addActionRowComponents(backRow);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed-based fallback — add as a regular component row
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
components: [...(message.components || []), backRow],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an embed showing the results of using an item
|
* Resolves an item URL (icon or image) for use in CV2 components.
|
||||||
|
* Handles both local assets and remote URLs.
|
||||||
|
* Pushes AttachmentBuilders to `files` array for local assets.
|
||||||
*/
|
*/
|
||||||
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null {
|
||||||
const embed = new EmbedBuilder();
|
if (!url) return null;
|
||||||
|
|
||||||
|
if (isLocalAssetUrl(url)) {
|
||||||
|
const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
const fileName = defaultName(url);
|
||||||
|
if (!files.find(f => f.name === fileName)) {
|
||||||
|
files.push(new AttachmentBuilder(filePath, { name: fileName }));
|
||||||
|
}
|
||||||
|
return `attachment://${fileName}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveAssetUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Components V2 message showing the result of opening a lootbox.
|
||||||
|
* Falls back to a simple embed for non-lootbox item usage.
|
||||||
|
*/
|
||||||
|
export function getLootboxResultMessage(
|
||||||
|
results: any[],
|
||||||
|
item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any }
|
||||||
|
) {
|
||||||
const files: AttachmentBuilder[] = [];
|
const files: AttachmentBuilder[] = [];
|
||||||
const otherMessages: string[] = [];
|
const otherMessages: string[] = [];
|
||||||
let lootResult: any = null;
|
let lootResult: any = null;
|
||||||
|
|
||||||
for (const res of results) {
|
for (const res of results) {
|
||||||
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
|
||||||
lootResult = res;
|
lootResult = res;
|
||||||
} else {
|
} else {
|
||||||
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
otherMessages.push(typeof res === "string" ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Configuration
|
// If no loot result, fall back to a simple embed (non-lootbox item usage)
|
||||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
if (!lootResult) {
|
||||||
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
|
const embed = new EmbedBuilder()
|
||||||
embed.setTimestamp();
|
.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!")
|
||||||
|
.setDescription(otherMessages.join("\n") || "Effect applied.")
|
||||||
if (lootResult) {
|
.setColor(0x2ecc71)
|
||||||
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
|
.setTimestamp();
|
||||||
|
return { embeds: [embed], files, components: undefined, flags: undefined };
|
||||||
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
|
}
|
||||||
const i = lootResult.item;
|
|
||||||
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
|
|
||||||
|
|
||||||
// Rarity Colors
|
|
||||||
const rarityColors: Record<string, number> = {
|
|
||||||
'C': 0x95A5A6, // Gray
|
|
||||||
'R': 0x3498DB, // Blue
|
|
||||||
'SR': 0x9B59B6, // Purple
|
|
||||||
'SSR': 0xF1C40F // Gold
|
|
||||||
};
|
|
||||||
|
|
||||||
const rarityKey = i.rarity || 'C';
|
|
||||||
if (rarityKey in rarityColors) {
|
|
||||||
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
|
|
||||||
} else {
|
|
||||||
embed.setColor(0x95A5A6);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i.image) {
|
|
||||||
if (isLocalAssetUrl(i.image)) {
|
|
||||||
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(imagePath)) {
|
|
||||||
const imageName = defaultName(i.image);
|
|
||||||
if (!files.find(f => f.name === imageName)) {
|
|
||||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
|
||||||
}
|
|
||||||
embed.setImage(`attachment://${imageName}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const imgUrl = resolveAssetUrl(i.image);
|
|
||||||
if (imgUrl) embed.setImage(imgUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
|
|
||||||
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
|
|
||||||
|
|
||||||
} else if (lootResult.rewardType === 'CURRENCY') {
|
|
||||||
embed.setColor(0xF1C40F);
|
|
||||||
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
|
|
||||||
} else if (lootResult.rewardType === 'XP') {
|
|
||||||
embed.setColor(0x2ECC71); // Green
|
|
||||||
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
|
|
||||||
} else {
|
|
||||||
// Nothing or Message
|
|
||||||
embed.setDescription(lootResult.message);
|
|
||||||
embed.setColor(0x95A5A6); // Gray
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Determine rarity key for theming
|
||||||
|
let rarityKey = "C";
|
||||||
|
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||||
|
rarityKey = lootResult.item.rarity || "C";
|
||||||
|
} else if (lootResult.rewardType === "CURRENCY") {
|
||||||
|
rarityKey = "CURRENCY";
|
||||||
|
} else if (lootResult.rewardType === "XP") {
|
||||||
|
rarityKey = "XP";
|
||||||
} else {
|
} else {
|
||||||
// Standard item usage
|
rarityKey = "NOTHING";
|
||||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
}
|
||||||
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
|
||||||
|
|
||||||
if (isLootbox && item && item.iconUrl) {
|
const config = getRarityConfig(rarityKey);
|
||||||
if (isLocalAssetUrl(item.iconUrl)) {
|
const container = new ContainerBuilder().setAccentColor(config.color);
|
||||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(iconPath)) {
|
// Header: lootbox name
|
||||||
const iconName = defaultName(item.iconUrl);
|
if (item?.name) {
|
||||||
if (!files.find(f => f.name === iconName)) {
|
container.addTextDisplayComponents(
|
||||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
new TextDisplayBuilder().setContent(`-# Opened: ${item.name}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build title and description based on reward type
|
||||||
|
let title = "";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||||
|
const i = lootResult.item;
|
||||||
|
const amountStr = lootResult.amount > 1 ? ` ×${lootResult.amount}` : "";
|
||||||
|
title = `${config.emoji} ${config.label} — ${i.name}${amountStr}`;
|
||||||
|
description = i.description || "";
|
||||||
|
description += (description ? "\n\n" : "") + `**${config.label}** · ×${lootResult.amount || 1} added to inventory`;
|
||||||
|
} else if (lootResult.rewardType === "CURRENCY") {
|
||||||
|
title = `${config.emoji} You found ${lootResult.amount.toLocaleString()} AU!`;
|
||||||
|
description = "Coins have been added to your balance.";
|
||||||
|
} else if (lootResult.rewardType === "XP") {
|
||||||
|
title = `${config.emoji} You gained ${lootResult.amount.toLocaleString()} XP!`;
|
||||||
|
description = "Experience has been added to your profile.";
|
||||||
|
} else {
|
||||||
|
title = `${config.emoji} Empty...`;
|
||||||
|
description = lootResult.message || "You found nothing inside.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main section with optional thumbnail
|
||||||
|
const section = new SectionBuilder().addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# ${title}`),
|
||||||
|
new TextDisplayBuilder().setContent(description)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thumbnail from iconUrl (use reward item's icon for ITEM, lootbox icon otherwise)
|
||||||
|
let thumbnailUrl: string | null = null;
|
||||||
|
const iconSource = lootResult.rewardType === "ITEM" ? lootResult.item?.iconUrl : item?.iconUrl;
|
||||||
|
if (iconSource) {
|
||||||
|
if (isLocalAssetUrl(iconSource)) {
|
||||||
|
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(iconSource).replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(iconPath)) {
|
||||||
|
const iconName = defaultName(iconSource);
|
||||||
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
|
thumbnailUrl = `attachment://${iconName}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thumbnailUrl = resolveAssetUrl(iconSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addSectionComponents(section);
|
||||||
|
|
||||||
|
// Media gallery for full item art (if imageUrl differs from iconUrl)
|
||||||
|
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||||
|
const imgSource = lootResult.item.imageUrl;
|
||||||
|
const iconSrc = lootResult.item.iconUrl;
|
||||||
|
if (imgSource && imgSource !== iconSrc) {
|
||||||
|
let displayImageUrl: string | null = null;
|
||||||
|
if (isLocalAssetUrl(imgSource)) {
|
||||||
|
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(imgSource).replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
const imageName = defaultName(imgSource);
|
||||||
|
if (!files.find(f => f.name === imageName)) {
|
||||||
|
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||||
}
|
}
|
||||||
embed.setThumbnail(`attachment://${iconName}`);
|
displayImageUrl = `attachment://${imageName}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
displayImageUrl = resolveAssetUrl(imgSource);
|
||||||
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
}
|
||||||
|
if (displayImageUrl) {
|
||||||
|
container.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
new MediaGalleryItemBuilder().setURL(displayImageUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otherMessages.length > 0 && lootResult) {
|
// Other effects (non-lootbox results like temp roles, XP boosts)
|
||||||
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
if (otherMessages.length > 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { embed, files };
|
return {
|
||||||
}
|
// TODO: remove cast once discord.js types include ContainerBuilder in MessageEditOptions
|
||||||
|
components: [container] as any,
|
||||||
function defaultName(path: string): string {
|
files,
|
||||||
return path.split("/").pop() || "image.png";
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Collection, Message, PermissionFlagsBits } from "discord.js";
|
|||||||
import type { TextBasedChannel } from "discord.js";
|
import type { TextBasedChannel } from "discord.js";
|
||||||
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch messages from a channel
|
* Fetch messages from a channel
|
||||||
@@ -30,7 +31,7 @@ async function processBatch(
|
|||||||
userId?: string
|
userId?: string
|
||||||
): Promise<{ deleted: number; skipped: number }> {
|
): Promise<{ deleted: number; skipped: number }> {
|
||||||
if (!('bulkDelete' in channel)) {
|
if (!('bulkDelete' in channel)) {
|
||||||
throw new Error("This channel type does not support bulk deletion");
|
throw new UserError("This channel type does not support bulk deletion");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by user if specified
|
// Filter by user if specified
|
||||||
@@ -54,7 +55,7 @@ async function processBatch(
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during bulk delete:", error);
|
console.error("Error during bulk delete:", error);
|
||||||
throw new Error("Failed to delete messages");
|
throw new SystemError("Failed to delete messages");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +77,12 @@ export const pruneService = {
|
|||||||
): Promise<PruneResult> {
|
): Promise<PruneResult> {
|
||||||
// Validate channel permissions
|
// Validate channel permissions
|
||||||
if (!('permissionsFor' in channel)) {
|
if (!('permissionsFor' in channel)) {
|
||||||
throw new Error("Cannot check permissions for this channel type");
|
throw new UserError("Cannot check permissions for this channel type");
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = channel.permissionsFor(channel.client.user!);
|
const permissions = channel.permissionsFor(channel.client.user!);
|
||||||
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
||||||
throw new Error("Missing permission to manage messages in this channel");
|
throw new UserError("Missing permission to manage messages in this channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { amount, userId, all } = options;
|
const { amount, userId, all } = options;
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export const PRUNE_CUSTOM_IDS = {
|
||||||
|
CONFIRM: "confirm_prune",
|
||||||
|
CANCEL: "cancel_prune",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface PruneOptions {
|
export interface PruneOptions {
|
||||||
amount?: number;
|
amount?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
||||||
import type { PruneResult, PruneProgress } from "./prune.types";
|
import { PRUNE_CUSTOM_IDS, type PruneResult, type PruneProgress } from "./prune.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a confirmation message for prune operations
|
* Creates a confirmation message for prune operations
|
||||||
@@ -25,12 +25,12 @@ export function getConfirmationMessage(
|
|||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const confirmButton = new ButtonBuilder()
|
const confirmButton = new ButtonBuilder()
|
||||||
.setCustomId("confirm_prune")
|
.setCustomId(PRUNE_CUSTOM_IDS.CONFIRM)
|
||||||
.setLabel("Confirm")
|
.setLabel("Confirm")
|
||||||
.setStyle(ButtonStyle.Danger);
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
const cancelButton = new ButtonBuilder()
|
||||||
.setCustomId("cancel_prune")
|
.setCustomId(PRUNE_CUSTOM_IDS.CANCEL)
|
||||||
.setLabel("Cancel")
|
.setLabel("Cancel")
|
||||||
.setStyle(ButtonStyle.Secondary);
|
.setStyle(ButtonStyle.Secondary);
|
||||||
|
|
||||||
|
|||||||
8
bot/modules/quest/quest.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const QUEST_CUSTOM_IDS = {
|
||||||
|
ACCEPT_PREFIX: "quest_accept:",
|
||||||
|
ACCEPT: (questId: number) => `quest_accept:${questId}`,
|
||||||
|
PAGE_PREV: "quest_page_prev",
|
||||||
|
PAGE_NEXT: "quest_page_next",
|
||||||
|
VIEW_ACTIVE: "quest_view_active",
|
||||||
|
VIEW_AVAILABLE: "quest_view_available",
|
||||||
|
} as const;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SeparatorSpacingSize,
|
SeparatorSpacingSize,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
|
import { QUEST_CUSTOM_IDS } from "./quest.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest entry with quest details and progress
|
* Quest entry with quest details and progress
|
||||||
@@ -43,6 +44,12 @@ const COLORS = {
|
|||||||
COMPLETED: 0xf1c40f // Gold - completed
|
COMPLETED: 0xf1c40f // Gold - completed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Max quests per page. Discord counts all nested components toward a 40 total limit:
|
||||||
|
// Fixed: 1 container + 2 header + 1 nav row + 2 nav buttons + 1 pagination row + 2 pagination buttons = 9
|
||||||
|
// Per quest (available): 1 separator + 3 text + 1 action row + 1 button = 6
|
||||||
|
// Budget: 9 + 6×5 = 39 <= 40
|
||||||
|
const QUESTS_PER_PAGE = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats quest rewards object into a human-readable string
|
* Formats quest rewards object into a human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -70,15 +77,22 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
|
|||||||
/**
|
/**
|
||||||
* Creates Components v2 containers for the quest list (active quests only)
|
* Creates Components v2 containers for the quest list (active quests only)
|
||||||
*/
|
*/
|
||||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
export function getQuestListComponents(userQuests: QuestEntry[], page: number = 0): ContainerBuilder[] {
|
||||||
// Filter to only show in-progress quests (not completed)
|
// Filter to only show in-progress quests (not completed)
|
||||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(activeQuests.length / QUESTS_PER_PAGE));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const pageQuests = activeQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
const container = new ContainerBuilder()
|
||||||
.setAccentColor(COLORS.ACTIVE)
|
.setAccentColor(COLORS.ACTIVE)
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
new TextDisplayBuilder().setContent(
|
||||||
|
totalPages > 1
|
||||||
|
? `-# Your active quests — Page ${safePage + 1}/${totalPages}`
|
||||||
|
: "-# Your active quests"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeQuests.length === 0) {
|
if (activeQuests.length === 0) {
|
||||||
@@ -89,7 +103,7 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
|||||||
return [container];
|
return [container];
|
||||||
}
|
}
|
||||||
|
|
||||||
activeQuests.forEach((entry) => {
|
pageQuests.forEach((entry) => {
|
||||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
@@ -113,12 +127,20 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
|||||||
/**
|
/**
|
||||||
* Creates Components v2 containers for available quests with inline accept buttons
|
* Creates Components v2 containers for available quests with inline accept buttons
|
||||||
*/
|
*/
|
||||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[], page: number = 0): ContainerBuilder[] {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(availableQuests.length / QUESTS_PER_PAGE));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const pageQuests = availableQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
const container = new ContainerBuilder()
|
||||||
.setAccentColor(COLORS.AVAILABLE)
|
.setAccentColor(COLORS.AVAILABLE)
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
new TextDisplayBuilder().setContent(
|
||||||
|
totalPages > 1
|
||||||
|
? `-# Quests you can accept — Page ${safePage + 1}/${totalPages}`
|
||||||
|
: "-# Quests you can accept"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableQuests.length === 0) {
|
if (availableQuests.length === 0) {
|
||||||
@@ -129,10 +151,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
|||||||
return [container];
|
return [container];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
pageQuests.forEach((quest) => {
|
||||||
const questsToShow = availableQuests.slice(0, 10);
|
|
||||||
|
|
||||||
questsToShow.forEach((quest) => {
|
|
||||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||||
@@ -151,7 +170,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
|||||||
container.addActionRowComponents(
|
container.addActionRowComponents(
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(`quest_accept:${quest.id}`)
|
.setCustomId(QUEST_CUSTOM_IDS.ACCEPT(quest.id))
|
||||||
.setLabel("Accept Quest")
|
.setLabel("Accept Quest")
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("✅")
|
.setEmoji("✅")
|
||||||
@@ -163,24 +182,43 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns action rows for navigation only
|
* Returns action rows for navigation and pagination
|
||||||
*/
|
*/
|
||||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
// Navigation row
|
const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE));
|
||||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
// Pagination row (only if more than one page)
|
||||||
|
if (totalPages > 1) {
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(QUEST_CUSTOM_IDS.PAGE_PREV)
|
||||||
|
.setLabel("◀ Prev")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page <= 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(QUEST_CUSTOM_IDS.PAGE_NEXT)
|
||||||
|
.setLabel("Next ▶")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page >= totalPages - 1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab navigation row
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_active")
|
.setCustomId(QUEST_CUSTOM_IDS.VIEW_ACTIVE)
|
||||||
.setLabel("📜 Active")
|
.setLabel("📜 Active")
|
||||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'active'),
|
.setDisabled(viewType === 'active'),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_available")
|
.setCustomId(QUEST_CUSTOM_IDS.VIEW_AVAILABLE)
|
||||||
.setLabel("🗺️ Available")
|
.setLabel("🗺️ Available")
|
||||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'available')
|
.setDisabled(viewType === 'available')
|
||||||
);
|
));
|
||||||
|
|
||||||
return [navRow];
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "./terminal.service";
|
||||||
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const terminalService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const guildConfig = await getGuildConfig(effectiveGuildId);
|
const guildConfig = await getGuildConfig(effectiveGuildId);
|
||||||
|
|
||||||
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
|
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||||
|
import { TRADE_CUSTOM_IDS } from "./trade.types";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -23,25 +24,25 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
|
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
|
||||||
if (customId === 'trade_cancel') {
|
if (customId === TRADE_CUSTOM_IDS.CANCEL) {
|
||||||
await handleCancel(interaction, threadId);
|
await handleCancel(interaction, threadId);
|
||||||
} else if (customId === 'trade_lock') {
|
} else if (customId === TRADE_CUSTOM_IDS.LOCK) {
|
||||||
await handleLock(interaction, threadId);
|
await handleLock(interaction, threadId);
|
||||||
} else if (customId === 'trade_confirm') {
|
} else if (customId === TRADE_CUSTOM_IDS.CONFIRM) {
|
||||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||||
} else if (customId === 'trade_add_money') {
|
} else if (customId === TRADE_CUSTOM_IDS.ADD_MONEY) {
|
||||||
await handleAddMoneyClick(interaction);
|
await handleAddMoneyClick(interaction);
|
||||||
} else if (customId === 'trade_money_modal') {
|
} else if (customId === TRADE_CUSTOM_IDS.MONEY_MODAL) {
|
||||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||||
} else if (customId === 'trade_add_item') {
|
} else if (customId === TRADE_CUSTOM_IDS.ADD_ITEM) {
|
||||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||||
} else if (customId === 'trade_select_item') {
|
} else if (customId === TRADE_CUSTOM_IDS.SELECT_ITEM) {
|
||||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
} else if (customId === 'trade_remove_item') {
|
} else if (customId === TRADE_CUSTOM_IDS.REMOVE_ITEM) {
|
||||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||||
} else if (customId === 'trade_remove_item_select') {
|
} else if (customId === TRADE_CUSTOM_IDS.REMOVE_ITEM_SELECT) {
|
||||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ async function handleAddMoneyClick(interaction: Interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) {
|
async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: string) {
|
||||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
const amountStr = interaction.fields.getTextInputValue(TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD);
|
||||||
const amount = BigInt(amountStr);
|
const amount = BigInt(amountStr);
|
||||||
|
|
||||||
if (amount < 0n) throw new UserError("Amount must be positive");
|
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||||
@@ -107,7 +108,7 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
|
|||||||
description: `Rarity: ${entry.item.rarity} `
|
description: `Rarity: ${entry.item.rarity} `
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
|
const { components } = getItemSelectMenu(options, TRADE_CUSTOM_IDS.SELECT_ITEM, 'Select an item to add');
|
||||||
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
|
|||||||
value: i.id.toString(),
|
value: i.id.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
|
const { components } = getItemSelectMenu(options, TRADE_CUSTOM_IDS.REMOVE_ITEM_SELECT, 'Select an item to remove');
|
||||||
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
export const TRADE_CUSTOM_IDS = {
|
||||||
|
PREFIX: "trade_",
|
||||||
|
ADD_ITEM: "trade_add_item",
|
||||||
|
ADD_MONEY: "trade_add_money",
|
||||||
|
REMOVE_ITEM: "trade_remove_item",
|
||||||
|
LOCK: "trade_lock",
|
||||||
|
CANCEL: "trade_cancel",
|
||||||
|
CONFIRM: "trade_confirm",
|
||||||
|
MONEY_MODAL: "trade_money_modal",
|
||||||
|
MONEY_AMOUNT_FIELD: "amount",
|
||||||
|
SELECT_ITEM: "trade_select_item",
|
||||||
|
REMOVE_ITEM_SELECT: "trade_remove_item_select",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface TradeItem {
|
export interface TradeItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { TradeSession, TradeParticipant } from "./trade.types";
|
import { TRADE_CUSTOM_IDS, type TradeSession, type TradeParticipant } from "./trade.types";
|
||||||
|
|
||||||
const EMBED_COLOR = 0xFFD700; // Gold
|
const EMBED_COLOR = 0xFFD700; // Gold
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ export function getTradeDashboard(session: TradeSession) {
|
|||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_ITEM).setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.ADD_MONEY).setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.REMOVE_ITEM).setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.LOCK).setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
new ButtonBuilder().setCustomId(TRADE_CUSTOM_IDS.CANCEL).setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
return { embeds: [embed], components: [row] };
|
||||||
@@ -57,11 +57,11 @@ export function getTradeCompletedEmbed(session: TradeSession) {
|
|||||||
|
|
||||||
export function getTradeMoneyModal() {
|
export function getTradeMoneyModal() {
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
.setCustomId('trade_money_modal')
|
.setCustomId(TRADE_CUSTOM_IDS.MONEY_MODAL)
|
||||||
.setTitle('Add Money');
|
.setTitle('Add Money');
|
||||||
|
|
||||||
const input = new TextInputBuilder()
|
const input = new TextInputBuilder()
|
||||||
.setCustomId('amount')
|
.setCustomId(TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD)
|
||||||
.setLabel("Amount to trade")
|
.setLabel("Amount to trade")
|
||||||
.setStyle(TextInputStyle.Short)
|
.setStyle(TextInputStyle.Short)
|
||||||
.setPlaceholder("100")
|
.setPlaceholder("100")
|
||||||
|
|||||||
@@ -1,116 +1,38 @@
|
|||||||
# Trivia - Components v2 Implementation
|
# Trivia UI
|
||||||
|
|
||||||
This trivia feature uses **Discord Components v2** for a premium visual experience.
|
The trivia command uses Discord Components v2 for the question, result, and timeout states.
|
||||||
|
|
||||||
## 🎨 Visual Features
|
## Files
|
||||||
|
|
||||||
### **Container with Accent Colors**
|
```text
|
||||||
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
|
|
||||||
- **🟢 Easy**: Green accent bar (`0x57F287`)
|
|
||||||
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
|
|
||||||
- **🔴 Hard**: Red accent bar (`0xED4245`)
|
|
||||||
|
|
||||||
### **Modern Layout Components**
|
|
||||||
- **TextDisplay** - Rich markdown formatting for question text
|
|
||||||
- **Separator** - Visual spacing between sections
|
|
||||||
- **Container** - Groups all content with difficulty-based styling
|
|
||||||
|
|
||||||
### **Interactive Features**
|
|
||||||
✅ **Give Up Button** - Players can forfeit if they're unsure
|
|
||||||
✅ **Disabled Answer Buttons** - After answering, buttons show:
|
|
||||||
- ✅ Green for correct answer
|
|
||||||
- ❌ Red for user's incorrect answer
|
|
||||||
- Gray for other options
|
|
||||||
|
|
||||||
✅ **Time Display** - Shows both relative time (`in 30s`) and seconds remaining
|
|
||||||
✅ **Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
|
|
||||||
|
|
||||||
## 📁 File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
bot/modules/trivia/
|
bot/modules/trivia/
|
||||||
├── trivia.view.ts # Components v2 view functions
|
trivia.types.ts
|
||||||
├── trivia.interaction.ts # Button interaction handler
|
trivia.view.ts
|
||||||
└── README.md # This file
|
trivia.interaction.ts
|
||||||
|
|
||||||
bot/commands/economy/
|
bot/commands/economy/trivia.ts
|
||||||
└── trivia.ts # /trivia slash command
|
shared/modules/trivia/trivia.service.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Technical Details
|
## What the view layer does
|
||||||
|
|
||||||
### Components v2 Requirements
|
- renders the active question as a Components v2 container
|
||||||
- Uses `MessageFlags.IsComponentsV2` flag
|
- colors the container by difficulty
|
||||||
- No `embeds` or `content` fields (uses TextDisplay instead)
|
- renders answer buttons from the session's shuffled answers
|
||||||
- Numeric component types:
|
- renders separate result and timeout views with disabled buttons
|
||||||
- `1` - Action Row
|
|
||||||
- `2` - Button
|
|
||||||
- `10` - Text Display
|
|
||||||
- `14` - Separator
|
|
||||||
- `17` - Container
|
|
||||||
- Max 40 components per message (vs 5 for legacy)
|
|
||||||
|
|
||||||
### Button Styles
|
## Current interaction flow
|
||||||
- **Secondary (2)**: Gray - Used for answer buttons
|
|
||||||
- **Success (3)**: Green - Used for "True" and correct answers
|
|
||||||
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
|
|
||||||
|
|
||||||
## 🎮 User Experience Flow
|
1. `/trivia` checks cooldown before deferring.
|
||||||
|
2. `startTrivia()` deducts the entry fee and creates the session.
|
||||||
|
3. `getTriviaQuestionView()` renders the prompt.
|
||||||
|
4. `trivia.interaction.ts` compares the clicked answer with the session's `correctIndex`.
|
||||||
|
5. `submitAnswer()` finalizes the session and the view swaps to success, failure, or timeout output.
|
||||||
|
|
||||||
1. User runs `/trivia`
|
The command also schedules a timeout cleanup with a 5-second grace period after `config.trivia.timeoutSeconds`.
|
||||||
2. Sees question in a Container with difficulty-based accent color
|
|
||||||
3. Can choose to:
|
|
||||||
- Select an answer (A/B/C/D or True/False)
|
|
||||||
- Give up using the 🏳️ button
|
|
||||||
4. After answering, sees result with:
|
|
||||||
- Disabled buttons showing correct/incorrect answers
|
|
||||||
- Container with result-based accent color (green/red/yellow)
|
|
||||||
- Reward or penalty information
|
|
||||||
|
|
||||||
## 🌟 Visual Examples
|
## Custom IDs
|
||||||
|
|
||||||
### Question Display
|
- answer buttons: `TRIVIA_CUSTOM_IDS.ANSWER(sessionId, index)`
|
||||||
```
|
- give up: `TRIVIA_CUSTOM_IDS.GIVE_UP(sessionId)`
|
||||||
┌─[GREEN]─────────────────────────┐
|
- result/timeout buttons use non-interactive result IDs
|
||||||
│ # 🎯 Trivia Challenge │
|
|
||||||
│ 🟢 Easy • 📚 Geography │
|
|
||||||
│ ─────────────────────────── │
|
|
||||||
│ ### What is the capital of │
|
|
||||||
│ France? │
|
|
||||||
│ │
|
|
||||||
│ ⏱️ Time: in 30s (30s) │
|
|
||||||
│ 💰 Stakes: 50 AU ➜ 100 AU │
|
|
||||||
│ 👤 Player: Username │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
[🇦 A: Paris] [🇧 B: London]
|
|
||||||
[🇨 C: Berlin] [🇩 D: Madrid]
|
|
||||||
[🏳️ Give Up]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Result Display (Correct)
|
|
||||||
```
|
|
||||||
┌─[GREEN]─────────────────────────┐
|
|
||||||
│ # 🎉 Correct Answer! │
|
|
||||||
│ ### What is the capital of │
|
|
||||||
│ France? │
|
|
||||||
│ ─────────────────────────── │
|
|
||||||
│ ✅ Your answer: Paris │
|
|
||||||
│ │
|
|
||||||
│ 💰 Reward: +100 AU │
|
|
||||||
│ │
|
|
||||||
│ 🏆 Great job! Keep it up! │
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
[✅ A: Paris] [❌ B: London]
|
|
||||||
[❌ C: Berlin] [❌ D: Madrid]
|
|
||||||
(all buttons disabled)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements:
|
|
||||||
- [ ] Thumbnail images based on trivia category
|
|
||||||
- [ ] Progress bar for time remaining
|
|
||||||
- [ ] Streak counter display
|
|
||||||
- [ ] Category-specific accent colors
|
|
||||||
- [ ] Media Gallery for image-based questions
|
|
||||||
- [ ] Leaderboard integration in results
|
|
||||||
|
|||||||
7
bot/modules/trivia/trivia.types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const TRIVIA_CUSTOM_IDS = {
|
||||||
|
PREFIX: "trivia_",
|
||||||
|
ANSWER: (sessionId: string, index: number) => `trivia_answer_${sessionId}_${index}`,
|
||||||
|
GIVE_UP: (sessionId: string) => `trivia_giveup_${sessionId}`,
|
||||||
|
RESULT: (index: number) => `trivia_result_${index}`,
|
||||||
|
TIMEOUT: (index: number) => `trivia_timeout_${index}`,
|
||||||
|
} as const;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MessageFlags } from "discord.js";
|
import { MessageFlags } from "discord.js";
|
||||||
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { TRIVIA_CUSTOM_IDS } from "./trivia.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color based on difficulty level
|
* Get color based on difficulty level
|
||||||
@@ -97,14 +98,14 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
|
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, trueIndex),
|
||||||
label: 'True',
|
label: 'True',
|
||||||
style: 3, // Success
|
style: 3, // Success
|
||||||
emoji: { name: '✅' }
|
emoji: { name: '✅' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
|
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, falseIndex),
|
||||||
label: 'False',
|
label: 'False',
|
||||||
style: 4, // Danger
|
style: 4, // Danger
|
||||||
emoji: { name: '❌' }
|
emoji: { name: '❌' }
|
||||||
@@ -129,7 +130,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_answer_${sessionId}_${i}`,
|
custom_id: TRIVIA_CUSTOM_IDS.ANSWER(sessionId, i),
|
||||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: 2, // Secondary
|
style: 2, // Secondary
|
||||||
emoji: { name: emoji }
|
emoji: { name: emoji }
|
||||||
@@ -145,7 +146,7 @@ export function getTriviaQuestionView(session: TriviaSession, username: string):
|
|||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_giveup_${sessionId}`,
|
custom_id: TRIVIA_CUSTOM_IDS.GIVE_UP(sessionId),
|
||||||
label: 'Give Up',
|
label: 'Give Up',
|
||||||
style: 4, // Danger
|
style: 4, // Danger
|
||||||
emoji: { name: '🏳️' }
|
emoji: { name: '🏳️' }
|
||||||
@@ -245,7 +246,7 @@ export function getTriviaResultView(
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_result_${i}`,
|
custom_id: TRIVIA_CUSTOM_IDS.RESULT(i),
|
||||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
||||||
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
||||||
@@ -318,7 +319,7 @@ export function getTriviaTimeoutView(
|
|||||||
|
|
||||||
buttonRow.components.push({
|
buttonRow.components.push({
|
||||||
type: 2, // Button
|
type: 2, // Button
|
||||||
custom_id: `trivia_timeout_${i}`,
|
custom_id: TRIVIA_CUSTOM_IDS.TIMEOUT(i),
|
||||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||||
style: isCorrect ? 3 : 2, // Success : Secondary
|
style: isCorrect ? 3 : 2, // Success : Secondary
|
||||||
emoji: { name: isCorrect ? '✅' : emoji },
|
emoji: { name: isCorrect ? '✅' : emoji },
|
||||||
|
|||||||
3
bot/modules/user/user.types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const ENROLLMENT_CUSTOM_IDS = {
|
||||||
|
ENROLL: "enrollment",
|
||||||
|
} as const;
|
||||||
27
bun.lock
@@ -6,9 +6,11 @@
|
|||||||
"name": "app",
|
"name": "app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.89",
|
"@napi-rs/canvas": "^0.1.89",
|
||||||
|
"chess.js": "^1.4.0",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
@@ -25,11 +27,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imgly/background-removal": "^1.7.0",
|
"@imgly/background-removal": "^1.7.0",
|
||||||
|
"chess.js": "^1.4.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-chessboard": "^5.10.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
},
|
},
|
||||||
@@ -97,6 +102,14 @@
|
|||||||
|
|
||||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
@@ -335,12 +348,16 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
|
"chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="],
|
||||||
|
|
||||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
@@ -437,6 +454,8 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
@@ -467,10 +486,16 @@
|
|||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-chessboard": ["react-chessboard@5.10.0", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-Y3PgaCVhnDG3IaQfu86OzTSEIEAUtuU5XwmHWnx3tcFOX7lSoAq81ZFX3MBj6y5a6FzDMTczMVmkkrV2CzTrIw=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
|
"react-router": ["react-router@7.13.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA=="],
|
||||||
|
|
||||||
|
"react-router-dom": ["react-router-dom@7.13.2", "", { "dependencies": { "react-router": "7.13.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
@@ -479,6 +504,8 @@
|
|||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|||||||
492
docs/api.md
@@ -1,492 +0,0 @@
|
|||||||
# Aurora API Reference
|
|
||||||
|
|
||||||
REST API server for Aurora bot management. Base URL: `http://localhost:3000`
|
|
||||||
|
|
||||||
## Common Response Formats
|
|
||||||
|
|
||||||
**Success Responses:**
|
|
||||||
- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }`
|
|
||||||
- List operations: `{ items: [...], total: number }`
|
|
||||||
- Mutations: `{ success: true, resource: {...} }`
|
|
||||||
|
|
||||||
**Error Responses:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Brief error message",
|
|
||||||
"details": "Optional detailed error information"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**HTTP Status Codes:**
|
|
||||||
| Code | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| 200 | Success |
|
|
||||||
| 201 | Created |
|
|
||||||
| 204 | No Content (successful DELETE) |
|
|
||||||
| 400 | Bad Request (validation error) |
|
|
||||||
| 404 | Not Found |
|
|
||||||
| 409 | Conflict (e.g., duplicate name) |
|
|
||||||
| 429 | Too Many Requests |
|
|
||||||
| 500 | Internal Server Error |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Health
|
|
||||||
|
|
||||||
### `GET /api/health`
|
|
||||||
Returns server health status.
|
|
||||||
|
|
||||||
**Response:** `{ "status": "ok", "timestamp": 1234567890 }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Items
|
|
||||||
|
|
||||||
### `GET /api/items`
|
|
||||||
List all items with optional filtering.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `search` | string | Filter by name/description |
|
|
||||||
| `type` | string | Filter by item type |
|
|
||||||
| `rarity` | string | Filter by rarity (C, R, SR, SSR) |
|
|
||||||
| `limit` | number | Max results (default: 100) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:** `{ "items": [...], "total": number }`
|
|
||||||
|
|
||||||
### `GET /api/items/:id`
|
|
||||||
Get single item by ID.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Health Potion",
|
|
||||||
"description": "Restores HP",
|
|
||||||
"type": "CONSUMABLE",
|
|
||||||
"rarity": "C",
|
|
||||||
"price": "100",
|
|
||||||
"iconUrl": "/assets/items/1.png",
|
|
||||||
"imageUrl": "/assets/items/1.png",
|
|
||||||
"usageData": { "consume": true, "effects": [] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/items`
|
|
||||||
Create new item. Supports JSON or multipart/form-data with image.
|
|
||||||
|
|
||||||
**Body (JSON):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Health Potion",
|
|
||||||
"description": "Restores HP",
|
|
||||||
"type": "CONSUMABLE",
|
|
||||||
"rarity": "C",
|
|
||||||
"price": "100",
|
|
||||||
"iconUrl": "/assets/items/placeholder.png",
|
|
||||||
"imageUrl": "/assets/items/placeholder.png",
|
|
||||||
"usageData": { "consume": true, "effects": [] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Body (Multipart):**
|
|
||||||
- `data`: JSON string with item fields
|
|
||||||
- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB)
|
|
||||||
|
|
||||||
### `PUT /api/items/:id`
|
|
||||||
Update existing item.
|
|
||||||
|
|
||||||
### `DELETE /api/items/:id`
|
|
||||||
Delete item and associated asset.
|
|
||||||
|
|
||||||
### `POST /api/items/:id/icon`
|
|
||||||
Upload/replace item image. Accepts multipart/form-data with `image` field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Users
|
|
||||||
|
|
||||||
### `GET /api/users`
|
|
||||||
List all users with optional filtering and sorting.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `search` | string | Filter by username (partial match) |
|
|
||||||
| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) |
|
|
||||||
| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:** `{ "users": [...], "total": number }`
|
|
||||||
|
|
||||||
### `GET /api/users/:id`
|
|
||||||
Get single user by Discord ID.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "123456789012345678",
|
|
||||||
"username": "Player1",
|
|
||||||
"balance": "1000",
|
|
||||||
"xp": "500",
|
|
||||||
"level": 5,
|
|
||||||
"dailyStreak": 3,
|
|
||||||
"isActive": true,
|
|
||||||
"classId": "1",
|
|
||||||
"class": { "id": "1", "name": "Warrior", "balance": "5000" },
|
|
||||||
"settings": {},
|
|
||||||
"createdAt": "2024-01-01T00:00:00Z",
|
|
||||||
"updatedAt": "2024-01-15T12:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/users/:id`
|
|
||||||
Update user fields.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "NewName",
|
|
||||||
"balance": "2000",
|
|
||||||
"xp": "750",
|
|
||||||
"level": 10,
|
|
||||||
"dailyStreak": 5,
|
|
||||||
"classId": "1",
|
|
||||||
"isActive": true,
|
|
||||||
"settings": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/users/:id/inventory`
|
|
||||||
Get user's inventory with item details.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"inventory": [
|
|
||||||
{
|
|
||||||
"userId": "123456789012345678",
|
|
||||||
"itemId": 1,
|
|
||||||
"quantity": "5",
|
|
||||||
"item": { "id": 1, "name": "Health Potion", ... }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/users/:id/inventory`
|
|
||||||
Add item to user inventory.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"itemId": 1,
|
|
||||||
"quantity": "5"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/users/:id/inventory/:itemId`
|
|
||||||
Remove item from user inventory. Use query param `amount` to specify quantity (default: 1).
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `amount` | number | Amount to remove (default: 1) |
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Classes
|
|
||||||
|
|
||||||
### `GET /api/classes`
|
|
||||||
List all classes.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"classes": [
|
|
||||||
{ "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/classes`
|
|
||||||
Create new class.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "2",
|
|
||||||
"name": "Mage",
|
|
||||||
"balance": "0",
|
|
||||||
"roleId": "987654321"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/classes/:id`
|
|
||||||
Update class.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Updated Name",
|
|
||||||
"balance": "10000",
|
|
||||||
"roleId": "111222333"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/classes/:id`
|
|
||||||
Delete class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Moderation
|
|
||||||
|
|
||||||
### `GET /api/moderation`
|
|
||||||
List moderation cases with optional filtering.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `userId` | string | Filter by target user ID |
|
|
||||||
| `moderatorId` | string | Filter by moderator ID |
|
|
||||||
| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` |
|
|
||||||
| `active` | boolean | Filter by active status |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cases": [
|
|
||||||
{
|
|
||||||
"id": "1",
|
|
||||||
"caseId": "CASE-0001",
|
|
||||||
"type": "warn",
|
|
||||||
"userId": "123456789",
|
|
||||||
"username": "User1",
|
|
||||||
"moderatorId": "987654321",
|
|
||||||
"moderatorName": "Mod1",
|
|
||||||
"reason": "Spam",
|
|
||||||
"metadata": {},
|
|
||||||
"active": true,
|
|
||||||
"createdAt": "2024-01-15T12:00:00Z",
|
|
||||||
"resolvedAt": null,
|
|
||||||
"resolvedBy": null,
|
|
||||||
"resolvedReason": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/moderation/:caseId`
|
|
||||||
Get single case by case ID (e.g., `CASE-0001`).
|
|
||||||
|
|
||||||
### `POST /api/moderation`
|
|
||||||
Create new moderation case.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "warn",
|
|
||||||
"userId": "123456789",
|
|
||||||
"username": "User1",
|
|
||||||
"moderatorId": "987654321",
|
|
||||||
"moderatorName": "Mod1",
|
|
||||||
"reason": "Rule violation",
|
|
||||||
"metadata": { "duration": "24h" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/moderation/:caseId/clear`
|
|
||||||
Clear/resolve a moderation case.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"clearedBy": "987654321",
|
|
||||||
"clearedByName": "Mod1",
|
|
||||||
"reason": "Appeal accepted"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Transactions
|
|
||||||
|
|
||||||
### `GET /api/transactions`
|
|
||||||
List economy transactions.
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `userId` | string | Filter by user ID |
|
|
||||||
| `type` | string | Filter by transaction type |
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
| `offset` | number | Pagination offset |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transactions": [
|
|
||||||
{
|
|
||||||
"id": "1",
|
|
||||||
"userId": "123456789",
|
|
||||||
"relatedUserId": null,
|
|
||||||
"amount": "100",
|
|
||||||
"type": "DAILY_REWARD",
|
|
||||||
"description": "Daily reward (Streak: 3)",
|
|
||||||
"createdAt": "2024-01-15T12:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Transaction Types:**
|
|
||||||
- `DAILY_REWARD` - Daily claim reward
|
|
||||||
- `TRANSFER_IN` - Received from another user
|
|
||||||
- `TRANSFER_OUT` - Sent to another user
|
|
||||||
- `LOOTDROP_CLAIM` - Claimed lootdrop
|
|
||||||
- `SHOP_BUY` - Item purchase
|
|
||||||
- `QUEST_REWARD` - Quest completion reward
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lootdrops
|
|
||||||
|
|
||||||
### `GET /api/lootdrops`
|
|
||||||
List lootdrops (default limit 50, sorted by newest).
|
|
||||||
|
|
||||||
| Query Param | Type | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| `limit` | number | Max results (default: 50) |
|
|
||||||
|
|
||||||
**Response:** `{ "lootdrops": [...] }`
|
|
||||||
|
|
||||||
### `POST /api/lootdrops`
|
|
||||||
Spawn a lootdrop in a channel.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channelId": "1234567890",
|
|
||||||
"amount": 100,
|
|
||||||
"currency": "Gold"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `DELETE /api/lootdrops/:messageId`
|
|
||||||
Cancel and delete a lootdrop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quests
|
|
||||||
|
|
||||||
### `GET /api/quests`
|
|
||||||
List all quests.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Daily Login",
|
|
||||||
"description": "Login once",
|
|
||||||
"triggerEvent": "login",
|
|
||||||
"requirements": { "target": 1 },
|
|
||||||
"rewards": { "xp": 50, "balance": 100 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/quests`
|
|
||||||
Create new quest.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Daily Login",
|
|
||||||
"description": "Login once",
|
|
||||||
"triggerEvent": "login",
|
|
||||||
"target": 1,
|
|
||||||
"xpReward": 50,
|
|
||||||
"balanceReward": 100
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `PUT /api/quests/:id`
|
|
||||||
Update quest.
|
|
||||||
|
|
||||||
### `DELETE /api/quests/:id`
|
|
||||||
Delete quest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
### `GET /api/settings`
|
|
||||||
Get current bot configuration.
|
|
||||||
|
|
||||||
### `POST /api/settings`
|
|
||||||
Update configuration (partial merge supported).
|
|
||||||
|
|
||||||
### `GET /api/settings/meta`
|
|
||||||
Get Discord metadata (roles, channels, commands).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }],
|
|
||||||
"channels": [{ "id": "456", "name": "general", "type": 0 }],
|
|
||||||
"commands": [{ "name": "daily", "category": "economy" }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin Actions
|
|
||||||
|
|
||||||
### `POST /api/actions/reload-commands`
|
|
||||||
Reload bot slash commands.
|
|
||||||
|
|
||||||
### `POST /api/actions/clear-cache`
|
|
||||||
Clear internal caches.
|
|
||||||
|
|
||||||
### `POST /api/actions/maintenance-mode`
|
|
||||||
Toggle maintenance mode.
|
|
||||||
|
|
||||||
**Body:** `{ "enabled": true, "reason": "Updating..." }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stats
|
|
||||||
|
|
||||||
### `GET /api/stats`
|
|
||||||
Get full dashboard statistics.
|
|
||||||
|
|
||||||
### `GET /api/stats/activity`
|
|
||||||
Get activity aggregation (cached 5 min).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
### `GET /assets/items/:filename`
|
|
||||||
Serve item images. Cached 24 hours.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WebSocket
|
|
||||||
|
|
||||||
### `ws://localhost:3000/ws`
|
|
||||||
Real-time dashboard updates.
|
|
||||||
|
|
||||||
**Messages:**
|
|
||||||
- `STATS_UPDATE` - Periodic stats broadcast (every 5s when clients connected)
|
|
||||||
- `NEW_EVENT` - Real-time system events
|
|
||||||
- `PING/PONG` - Heartbeat
|
|
||||||
|
|
||||||
**Limits:** Max 10 concurrent connections, 16KB max payload, 60s idle timeout.
|
|
||||||
@@ -1,769 +0,0 @@
|
|||||||
# Aurora Admin Panel - Design Guidelines
|
|
||||||
|
|
||||||
## Design Philosophy
|
|
||||||
|
|
||||||
The Aurora Admin Panel embodies the intersection of celestial mystique and institutional precision. It is a command center for academy administration—powerful, sophisticated, and unmistakably authoritative. Every interface element should communicate control, clarity, and prestige.
|
|
||||||
|
|
||||||
**Core Principles:**
|
|
||||||
- **Authority over Friendliness**: This is an administrative tool, not a consumer app
|
|
||||||
- **Data Clarity**: Information density balanced with elegant presentation
|
|
||||||
- **Celestial Aesthetic**: Subtle cosmic theming that doesn't compromise functionality
|
|
||||||
- **Institutional Grade**: Professional, trustworthy, built to manage complex systems
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Visual Foundation
|
|
||||||
|
|
||||||
### Color System
|
|
||||||
|
|
||||||
**Background Hierarchy**
|
|
||||||
```
|
|
||||||
Level 0 (Base) #0A0A0F Eclipse Void - Deepest background
|
|
||||||
Level 1 (Container) #151520 Midnight Canvas - Cards, panels, modals
|
|
||||||
Level 2 (Surface) #1E1B4B Nebula Surface - Elevated elements
|
|
||||||
Level 3 (Raised) #2D2A5F Stellar Overlay - Hover states, dropdowns
|
|
||||||
```
|
|
||||||
|
|
||||||
**Text Hierarchy**
|
|
||||||
```
|
|
||||||
Primary Text #F9FAFB Starlight White - Headings, key data
|
|
||||||
Secondary Text #E5E7EB Stardust Silver - Body text, labels
|
|
||||||
Tertiary Text #9CA3AF Cosmic Gray - Helper text, timestamps
|
|
||||||
Disabled Text #6B7280 Void Gray - Inactive elements
|
|
||||||
```
|
|
||||||
|
|
||||||
**Brand Accents**
|
|
||||||
```
|
|
||||||
Primary (Action) #8B5CF6 Aurora Purple - Primary buttons, links, active states
|
|
||||||
Secondary (Info) #3B82F6 Nebula Blue - Informational elements
|
|
||||||
Success #10B981 Emerald - Confirmations, positive indicators
|
|
||||||
Warning #F59E0B Amber - Cautions, alerts
|
|
||||||
Danger #DC2626 Crimson - Errors, destructive actions
|
|
||||||
Gold (Prestige) #FCD34D Celestial Gold - Premium features, highlights
|
|
||||||
```
|
|
||||||
|
|
||||||
**Constellation Tier Colors** (for data visualization)
|
|
||||||
```
|
|
||||||
Constellation A #FCD34D Celestial Gold
|
|
||||||
Constellation B #8B5CF6 Aurora Purple
|
|
||||||
Constellation C #3B82F6 Nebula Blue
|
|
||||||
Constellation D #6B7280 Slate Gray
|
|
||||||
```
|
|
||||||
|
|
||||||
**Semantic Colors**
|
|
||||||
```
|
|
||||||
Currency (AU) #FCD34D Gold - Astral Units indicators
|
|
||||||
Currency (CU) #8B5CF6 Purple - Constellation Units indicators
|
|
||||||
XP/Progress #3B82F6 Blue - Experience and progression
|
|
||||||
Activity #10B981 Green - Active users, live events
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
**Font Stack**
|
|
||||||
|
|
||||||
Primary (UI Text):
|
|
||||||
```css
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
```
|
|
||||||
- Clean, highly legible, modern
|
|
||||||
- Excellent at small sizes for data-dense interfaces
|
|
||||||
- Professional without being sterile
|
|
||||||
|
|
||||||
Display (Headings):
|
|
||||||
```css
|
|
||||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
|
||||||
```
|
|
||||||
- Geometric, slightly futuristic
|
|
||||||
- Use for page titles, section headers
|
|
||||||
- Reinforces celestial/institutional theme
|
|
||||||
|
|
||||||
Monospace (Data):
|
|
||||||
```css
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
```
|
|
||||||
- For numerical data, timestamps, IDs
|
|
||||||
- Improves scanability of tabular data
|
|
||||||
- Technical credibility
|
|
||||||
|
|
||||||
**Type Scale**
|
|
||||||
```
|
|
||||||
Display Large 48px / 3rem font-weight: 700 (Dashboard headers)
|
|
||||||
Display 36px / 2.25rem font-weight: 700 (Page titles)
|
|
||||||
Heading 1 30px / 1.875rem font-weight: 600 (Section titles)
|
|
||||||
Heading 2 24px / 1.5rem font-weight: 600 (Card headers)
|
|
||||||
Heading 3 20px / 1.25rem font-weight: 600 (Subsections)
|
|
||||||
Body Large 16px / 1rem font-weight: 400 (Emphasized body)
|
|
||||||
Body 14px / 0.875rem font-weight: 400 (Default text)
|
|
||||||
Body Small 13px / 0.8125rem font-weight: 400 (Secondary info)
|
|
||||||
Caption 12px / 0.75rem font-weight: 400 (Labels, hints)
|
|
||||||
Overline 11px / 0.6875rem font-weight: 600 (Uppercase labels)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Font Weight Usage**
|
|
||||||
- **700 (Bold)**: Display text, critical metrics
|
|
||||||
- **600 (Semibold)**: Headings, emphasized data
|
|
||||||
- **500 (Medium)**: Buttons, active tabs, selected items
|
|
||||||
- **400 (Regular)**: Body text, form inputs
|
|
||||||
- **Never use weights below 400** - maintain readability
|
|
||||||
|
|
||||||
### Spacing & Layout
|
|
||||||
|
|
||||||
**Base Unit**: 4px
|
|
||||||
|
|
||||||
**Spacing Scale**
|
|
||||||
```
|
|
||||||
xs 4px 0.25rem Tight spacing, icon gaps
|
|
||||||
sm 8px 0.5rem Form element spacing
|
|
||||||
md 16px 1rem Default component spacing
|
|
||||||
lg 24px 1.5rem Section spacing
|
|
||||||
xl 32px 2rem Major section breaks
|
|
||||||
2xl 48px 3rem Page section dividers
|
|
||||||
3xl 64px 4rem Major layout divisions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Container Widths**
|
|
||||||
```
|
|
||||||
Full Bleed 100% Full viewport width
|
|
||||||
Wide 1600px Wide dashboards, data tables
|
|
||||||
Standard 1280px Default content width
|
|
||||||
Narrow 960px Forms, focused content
|
|
||||||
Reading 720px Long-form text (documentation)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Grid System**
|
|
||||||
- 12-column grid for flexible layouts
|
|
||||||
- 24px gutters between columns
|
|
||||||
- Responsive breakpoints: 640px, 768px, 1024px, 1280px, 1536px
|
|
||||||
|
|
||||||
### Borders & Dividers
|
|
||||||
|
|
||||||
**Border Widths**
|
|
||||||
```
|
|
||||||
Hairline 0.5px Subtle dividers
|
|
||||||
Thin 1px Default borders
|
|
||||||
Medium 2px Emphasized borders, focus states
|
|
||||||
Thick 4px Accent bars, category indicators
|
|
||||||
```
|
|
||||||
|
|
||||||
**Border Colors**
|
|
||||||
```
|
|
||||||
Default #2D2A5F 15% opacity - Standard dividers
|
|
||||||
Subtle #2D2A5F 8% opacity - Very light separation
|
|
||||||
Emphasized #8B5CF6 30% opacity - Highlighted borders
|
|
||||||
Interactive #8B5CF6 60% opacity - Hover/focus states
|
|
||||||
```
|
|
||||||
|
|
||||||
**Border Radius**
|
|
||||||
```
|
|
||||||
None 0px Data tables, strict layouts
|
|
||||||
sm 4px Buttons, badges, pills
|
|
||||||
md 8px Cards, inputs, panels
|
|
||||||
lg 12px Large cards, modals
|
|
||||||
xl 16px Feature cards, images
|
|
||||||
2xl 24px Hero elements
|
|
||||||
full 9999px Circular elements, avatars
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Patterns
|
|
||||||
|
|
||||||
### Cards & Containers
|
|
||||||
|
|
||||||
**Standard Card**
|
|
||||||
```
|
|
||||||
Background: #151520 (Midnight Canvas)
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Border Radius: 8px
|
|
||||||
Padding: 24px
|
|
||||||
Shadow: 0 4px 16px rgba(0, 0, 0, 0.4)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Elevated Card** (hover/focus)
|
|
||||||
```
|
|
||||||
Background: #1E1B4B (Nebula Surface)
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.3)
|
|
||||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
|
||||||
Transform: translateY(-2px)
|
|
||||||
Transition: all 200ms ease
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stat Card** (metrics, KPIs)
|
|
||||||
```
|
|
||||||
Background: Linear gradient from #151520 to #1E1B4B
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Accent Border: 4px left border in tier/category color
|
|
||||||
Icon: Celestial icon in accent color
|
|
||||||
Typography: Large number (Display), small label (Overline)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Tables
|
|
||||||
|
|
||||||
**Table Structure**
|
|
||||||
```
|
|
||||||
Header Background: #1E1B4B
|
|
||||||
Header Text: #E5E7EB, 11px uppercase, 600 weight
|
|
||||||
Row Background: Alternating #0A0A0F / #151520
|
|
||||||
Row Hover: #2D2A5F with 40% opacity
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.1) between rows
|
|
||||||
Cell Padding: 12px 16px
|
|
||||||
```
|
|
||||||
|
|
||||||
**Column Styling**
|
|
||||||
- Left-align text columns
|
|
||||||
- Right-align numerical columns
|
|
||||||
- Monospace font for numbers, IDs, timestamps
|
|
||||||
- Icon + text combinations for status indicators
|
|
||||||
|
|
||||||
**Interactive Elements**
|
|
||||||
- Sortable headers with subtle arrow icons
|
|
||||||
- Hover state on entire row
|
|
||||||
- Click/select highlight with Aurora Purple tint
|
|
||||||
- Pagination in Nebula Blue
|
|
||||||
|
|
||||||
### Forms & Inputs
|
|
||||||
|
|
||||||
**Input Fields**
|
|
||||||
```
|
|
||||||
Background: #1E1B4B
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Border Radius: 6px
|
|
||||||
Padding: 10px 14px
|
|
||||||
Font Size: 14px
|
|
||||||
Text Color: #F9FAFB
|
|
||||||
|
|
||||||
Focus State:
|
|
||||||
Border: 2px solid #8B5CF6
|
|
||||||
Glow: 0 0 0 3px rgba(139, 92, 246, 0.2)
|
|
||||||
|
|
||||||
Error State:
|
|
||||||
Border: 1px solid #DC2626
|
|
||||||
Text: #DC2626 helper text below
|
|
||||||
|
|
||||||
Disabled State:
|
|
||||||
Background: #0A0A0F
|
|
||||||
Text: #6B7280
|
|
||||||
Cursor: not-allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
**Labels**
|
|
||||||
```
|
|
||||||
Font Size: 12px
|
|
||||||
Font Weight: 600
|
|
||||||
Text Color: #E5E7EB
|
|
||||||
Margin Bottom: 6px
|
|
||||||
```
|
|
||||||
|
|
||||||
**Select Dropdowns**
|
|
||||||
```
|
|
||||||
Same base styling as inputs
|
|
||||||
Dropdown Icon: Chevron in #9CA3AF
|
|
||||||
Menu Background: #2D2A5F
|
|
||||||
Menu Border: 1px solid rgba(139, 92, 246, 0.3)
|
|
||||||
Option Hover: #3B82F6 background
|
|
||||||
Selected: #8B5CF6 with checkmark icon
|
|
||||||
```
|
|
||||||
|
|
||||||
**Checkboxes & Radio Buttons**
|
|
||||||
```
|
|
||||||
Size: 18px × 18px
|
|
||||||
Border: 2px solid rgba(139, 92, 246, 0.4)
|
|
||||||
Border Radius: 4px (checkbox) / 50% (radio)
|
|
||||||
Checked: #8B5CF6 background with white checkmark
|
|
||||||
Hover: Glow effect rgba(139, 92, 246, 0.2)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
|
|
||||||
**Primary Button**
|
|
||||||
```
|
|
||||||
Background: #8B5CF6 (Aurora Purple)
|
|
||||||
Text: #FFFFFF
|
|
||||||
Padding: 10px 20px
|
|
||||||
Border Radius: 6px
|
|
||||||
Font Weight: 500
|
|
||||||
Shadow: 0 2px 8px rgba(139, 92, 246, 0.3)
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: #7C3AED (lighter purple)
|
|
||||||
Shadow: 0 4px 12px rgba(139, 92, 246, 0.4)
|
|
||||||
|
|
||||||
Active:
|
|
||||||
Background: #6D28D9 (darker purple)
|
|
||||||
Transform: scale(0.98)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secondary Button**
|
|
||||||
```
|
|
||||||
Background: transparent
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.5)
|
|
||||||
Text: #8B5CF6
|
|
||||||
Padding: 10px 20px
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: rgba(139, 92, 246, 0.1)
|
|
||||||
Border: 1px solid #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
**Destructive Button**
|
|
||||||
```
|
|
||||||
Background: #DC2626
|
|
||||||
Text: #FFFFFF
|
|
||||||
(Same structure as Primary)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ghost Button**
|
|
||||||
```
|
|
||||||
Background: transparent
|
|
||||||
Text: #E5E7EB
|
|
||||||
Padding: 8px 16px
|
|
||||||
|
|
||||||
Hover:
|
|
||||||
Background: rgba(139, 92, 246, 0.1)
|
|
||||||
Text: #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
**Button Sizes**
|
|
||||||
```
|
|
||||||
Small 8px 12px 12px text
|
|
||||||
Medium 10px 20px 14px text (default)
|
|
||||||
Large 12px 24px 16px text
|
|
||||||
```
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
**Sidebar Navigation**
|
|
||||||
```
|
|
||||||
Background: #0A0A0F with subtle gradient
|
|
||||||
Width: 260px (expanded) / 64px (collapsed)
|
|
||||||
Border Right: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
|
|
||||||
Nav Item:
|
|
||||||
Padding: 12px 16px
|
|
||||||
Border Radius: 6px
|
|
||||||
Font Size: 14px
|
|
||||||
Font Weight: 500
|
|
||||||
Icon Size: 20px
|
|
||||||
Gap: 12px between icon and text
|
|
||||||
|
|
||||||
Active State:
|
|
||||||
Background: rgba(139, 92, 246, 0.15)
|
|
||||||
Border Left: 4px solid #8B5CF6
|
|
||||||
Text: #8B5CF6
|
|
||||||
Icon: #8B5CF6
|
|
||||||
|
|
||||||
Hover State:
|
|
||||||
Background: rgba(139, 92, 246, 0.08)
|
|
||||||
Text: #F9FAFB
|
|
||||||
```
|
|
||||||
|
|
||||||
**Top Bar / Header**
|
|
||||||
```
|
|
||||||
Background: #0A0A0F with backdrop blur
|
|
||||||
Height: 64px
|
|
||||||
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Position: Sticky
|
|
||||||
Z-index: 100
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
- Logo / Academy name
|
|
||||||
- Global search
|
|
||||||
- Quick actions
|
|
||||||
- User profile dropdown
|
|
||||||
- Notification bell
|
|
||||||
```
|
|
||||||
|
|
||||||
**Breadcrumbs**
|
|
||||||
```
|
|
||||||
Font Size: 13px
|
|
||||||
Text Color: #9CA3AF
|
|
||||||
Separator: "/" or "›" in #6B7280
|
|
||||||
Current Page: #F9FAFB, 600 weight
|
|
||||||
Links: #9CA3AF, hover to #8B5CF6
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modals & Overlays
|
|
||||||
|
|
||||||
**Modal Structure**
|
|
||||||
```
|
|
||||||
Backdrop: rgba(0, 0, 0, 0.8) with backdrop blur
|
|
||||||
Modal Container: #151520
|
|
||||||
Border: 1px solid rgba(139, 92, 246, 0.2)
|
|
||||||
Border Radius: 12px
|
|
||||||
Shadow: 0 24px 48px rgba(0, 0, 0, 0.9)
|
|
||||||
Max Width: 600px (standard) / 900px (wide)
|
|
||||||
Padding: 32px
|
|
||||||
|
|
||||||
Header:
|
|
||||||
Border Bottom: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Padding: 0 0 20px 0
|
|
||||||
Font Size: 24px
|
|
||||||
Font Weight: 600
|
|
||||||
|
|
||||||
Footer:
|
|
||||||
Border Top: 1px solid rgba(139, 92, 246, 0.15)
|
|
||||||
Padding: 20px 0 0 0
|
|
||||||
Buttons: Right-aligned, 12px gap
|
|
||||||
```
|
|
||||||
|
|
||||||
**Toast Notifications**
|
|
||||||
```
|
|
||||||
Position: Top-right, 24px margin
|
|
||||||
Background: #2D2A5F
|
|
||||||
Border: 1px solid (color based on type)
|
|
||||||
Border Radius: 8px
|
|
||||||
Padding: 16px 20px
|
|
||||||
Max Width: 400px
|
|
||||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6)
|
|
||||||
|
|
||||||
Success: #10B981 border, green icon
|
|
||||||
Warning: #F59E0B border, amber icon
|
|
||||||
Error: #DC2626 border, red icon
|
|
||||||
Info: #3B82F6 border, blue icon
|
|
||||||
|
|
||||||
Animation: Slide in from right, fade out
|
|
||||||
Duration: 4 seconds (dismissible)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Visualization
|
|
||||||
|
|
||||||
**Charts & Graphs**
|
|
||||||
```
|
|
||||||
Background: #151520 or transparent
|
|
||||||
Grid Lines: rgba(139, 92, 246, 0.1)
|
|
||||||
Axis Labels: #9CA3AF, 12px
|
|
||||||
Data Points: Constellation tier colors or semantic colors
|
|
||||||
Tooltips: #2D2A5F background, white text
|
|
||||||
Legend: Horizontal, 12px, icons + labels
|
|
||||||
```
|
|
||||||
|
|
||||||
**Progress Bars**
|
|
||||||
```
|
|
||||||
Track: #1E1B4B
|
|
||||||
Fill: Linear gradient with tier/category color
|
|
||||||
Height: 8px (thin) / 12px (medium) / 16px (thick)
|
|
||||||
Border Radius: 9999px
|
|
||||||
Label: Above or inline, monospace numbers
|
|
||||||
```
|
|
||||||
|
|
||||||
**Badges & Pills**
|
|
||||||
```
|
|
||||||
Background: Semantic color with 15% opacity
|
|
||||||
Text: Semantic color (full saturation)
|
|
||||||
Border: 1px solid semantic color with 30% opacity
|
|
||||||
Padding: 4px 10px
|
|
||||||
Border Radius: 9999px
|
|
||||||
Font Size: 12px
|
|
||||||
Font Weight: 500
|
|
||||||
|
|
||||||
Status Examples:
|
|
||||||
Active: Green
|
|
||||||
Pending: Amber
|
|
||||||
Inactive: Gray
|
|
||||||
Error: Red
|
|
||||||
Premium: Gold
|
|
||||||
```
|
|
||||||
|
|
||||||
### Icons
|
|
||||||
|
|
||||||
**Icon System**
|
|
||||||
- Use consistent icon family (e.g., Lucide, Heroicons, Phosphor)
|
|
||||||
- Line-style icons, not filled (except for active states)
|
|
||||||
- Stroke width: 1.5px-2px
|
|
||||||
- Sizes: 16px (small), 20px (default), 24px (large), 32px (extra large)
|
|
||||||
|
|
||||||
**Icon Colors**
|
|
||||||
- Default: #9CA3AF (Cosmic Gray)
|
|
||||||
- Active/Selected: #8B5CF6 (Aurora Purple)
|
|
||||||
- Success: #10B981
|
|
||||||
- Warning: #F59E0B
|
|
||||||
- Error: #DC2626
|
|
||||||
|
|
||||||
**Celestial Icon Themes**
|
|
||||||
- Stars, constellations, orbits for branding
|
|
||||||
- Minimalist, geometric line art
|
|
||||||
- Avoid overly detailed or realistic astronomy images
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Animation & Motion
|
|
||||||
|
|
||||||
### Principles
|
|
||||||
- **Purposeful**: Animations guide attention and provide feedback
|
|
||||||
- **Subtle**: No distracting or excessive motion
|
|
||||||
- **Fast**: Snappy interactions (150-300ms)
|
|
||||||
- **Professional**: Ease curves that feel polished
|
|
||||||
|
|
||||||
### Timing Functions
|
|
||||||
```
|
|
||||||
ease-out Default for most interactions
|
|
||||||
ease-in-out Modal/panel transitions
|
|
||||||
ease-in Exit animations
|
|
||||||
spring Micro-interactions (subtle bounce)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard Durations
|
|
||||||
```
|
|
||||||
Instant 0ms State changes
|
|
||||||
Fast 150ms Button hover, color changes
|
|
||||||
Standard 200ms Card hover, dropdown open
|
|
||||||
Moderate 300ms Modal open, page transitions
|
|
||||||
Slow 500ms Large panel animations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Animations
|
|
||||||
|
|
||||||
**Hover Effects**
|
|
||||||
```css
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: [enhanced shadow];
|
|
||||||
```
|
|
||||||
|
|
||||||
**Focus States**
|
|
||||||
```css
|
|
||||||
transition: border 150ms ease-out, box-shadow 150ms ease-out;
|
|
||||||
border-color: #8B5CF6;
|
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Loading States**
|
|
||||||
```
|
|
||||||
Skeleton: Shimmer effect from left to right
|
|
||||||
Spinner: Rotating celestial icon or ring
|
|
||||||
Progress: Smooth bar fill with easing
|
|
||||||
```
|
|
||||||
|
|
||||||
**Page Transitions**
|
|
||||||
```
|
|
||||||
Fade in: Opacity 0 → 1 over 200ms
|
|
||||||
Slide up: TranslateY(20px) → 0 over 300ms
|
|
||||||
Blur fade: Blur + opacity for backdrop
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
### Breakpoints
|
|
||||||
```
|
|
||||||
Mobile < 640px
|
|
||||||
Tablet 640px - 1024px
|
|
||||||
Desktop > 1024px
|
|
||||||
Wide Desktop > 1536px
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mobile Adaptations
|
|
||||||
- Sidebar collapses to hamburger menu
|
|
||||||
- Cards stack vertically
|
|
||||||
- Tables become horizontally scrollable or convert to card view
|
|
||||||
- Reduce padding and spacing by 25-50%
|
|
||||||
- Larger touch targets (minimum 44px)
|
|
||||||
- Bottom navigation for primary actions
|
|
||||||
|
|
||||||
### Tablet Optimizations
|
|
||||||
- Hybrid layouts (sidebar can be toggled)
|
|
||||||
- Adaptive grid (4 columns → 2 columns)
|
|
||||||
- Touch-friendly sizing maintained
|
|
||||||
- Utilize available space efficiently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
### Color Contrast
|
|
||||||
- Maintain WCAG AA standards minimum (4.5:1 for normal text)
|
|
||||||
- Critical actions and text meet AAA standards (7:1)
|
|
||||||
- Never rely on color alone for information
|
|
||||||
|
|
||||||
### Focus Indicators
|
|
||||||
- Always visible focus states
|
|
||||||
- 2px Aurora Purple outline with 3px glow
|
|
||||||
- Logical tab order follows visual hierarchy
|
|
||||||
|
|
||||||
### Screen Readers
|
|
||||||
- Semantic HTML structure
|
|
||||||
- ARIA labels for icon-only buttons
|
|
||||||
- Status messages announced appropriately
|
|
||||||
- Table headers properly associated
|
|
||||||
|
|
||||||
### Keyboard Navigation
|
|
||||||
- All interactive elements accessible via keyboard
|
|
||||||
- Modal traps focus within itself
|
|
||||||
- Escape key closes overlays
|
|
||||||
- Arrow keys for navigation where appropriate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dark Mode Philosophy
|
|
||||||
|
|
||||||
**Aurora Admin is dark-first by design.** The interface assumes a dark environment and doesn't offer a light mode toggle. This decision is intentional:
|
|
||||||
|
|
||||||
- **Focus**: Dark reduces eye strain during extended admin sessions
|
|
||||||
- **Data Emphasis**: Light text on dark makes numbers/data more prominent
|
|
||||||
- **Celestial Theme**: Dark backgrounds reinforce the cosmic aesthetic
|
|
||||||
- **Professional**: Dark UIs feel more serious and technical
|
|
||||||
|
|
||||||
If light mode is ever required, avoid pure white—use off-white (#F9FAFB) backgrounds with careful contrast management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Theming & Customization
|
|
||||||
|
|
||||||
### Constellation Tier Theming
|
|
||||||
When displaying constellation-specific data:
|
|
||||||
- Use tier colors for accents, not backgrounds
|
|
||||||
- Apply colors to borders, icons, badges
|
|
||||||
- Maintain readability—don't overwhelm with color
|
|
||||||
|
|
||||||
### Admin Privilege Levels
|
|
||||||
Different admin roles can have subtle UI indicators:
|
|
||||||
- Super Admin: Gold accents
|
|
||||||
- Moderator: Purple accents
|
|
||||||
- Viewer: Blue accents
|
|
||||||
|
|
||||||
These are subtle hints, not dominant visual themes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Library Standards
|
|
||||||
|
|
||||||
### Consistency
|
|
||||||
- Reuse components extensively
|
|
||||||
- Maintain consistent spacing, sizing, behavior
|
|
||||||
- Document component variants clearly
|
|
||||||
- Avoid one-off custom elements
|
|
||||||
|
|
||||||
### Composability
|
|
||||||
- Build complex UIs from simple components
|
|
||||||
- Components should work together seamlessly
|
|
||||||
- Predictable prop APIs
|
|
||||||
- Flexible but not overly configurable
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Lazy load heavy components
|
|
||||||
- Virtualize long lists
|
|
||||||
- Optimize re-renders
|
|
||||||
- Compress and cache assets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style (UI Framework Agnostic)
|
|
||||||
|
|
||||||
### Class Naming
|
|
||||||
Use clear, semantic names:
|
|
||||||
```
|
|
||||||
.card-stat Not .cs or .c1
|
|
||||||
.button-primary Not .btn-p or .bp
|
|
||||||
.table-header Not .th or .t-h
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
```
|
|
||||||
/components
|
|
||||||
/ui Base components (buttons, inputs)
|
|
||||||
/layout Layout components (sidebar, header)
|
|
||||||
/data Data components (tables, charts)
|
|
||||||
/feedback Toasts, modals, alerts
|
|
||||||
/forms Form-specific components
|
|
||||||
```
|
|
||||||
|
|
||||||
### Style Organization
|
|
||||||
- Variables/tokens for all design values
|
|
||||||
- No magic numbers in components
|
|
||||||
- DRY—reuse common styles
|
|
||||||
- Mobile-first responsive approach
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Do's ✓
|
|
||||||
- Use established patterns from these guidelines
|
|
||||||
- Maintain consistent spacing throughout
|
|
||||||
- Prioritize data clarity and scannability
|
|
||||||
- Test with real data, not lorem ipsum
|
|
||||||
- Consider loading and empty states
|
|
||||||
- Provide clear feedback for all actions
|
|
||||||
- Use progressive disclosure for complex features
|
|
||||||
|
|
||||||
### Don'ts ✗
|
|
||||||
- Don't use bright, saturated colors outside defined palette
|
|
||||||
- Don't create custom components when standard ones exist
|
|
||||||
- Don't sacrifice accessibility for aesthetics
|
|
||||||
- Don't use decorative animations that distract
|
|
||||||
- Don't hide critical actions in nested menus
|
|
||||||
- Don't use tiny fonts (below 12px) for functional text
|
|
||||||
- Don't ignore error states and edge cases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quality Checklist
|
|
||||||
|
|
||||||
Before considering any UI complete:
|
|
||||||
|
|
||||||
**Visual**
|
|
||||||
- [ ] Colors match defined palette exactly
|
|
||||||
- [ ] Spacing uses the 4px grid system
|
|
||||||
- [ ] Typography follows scale and hierarchy
|
|
||||||
- [ ] Borders and shadows are consistent
|
|
||||||
- [ ] Icons are properly sized and aligned
|
|
||||||
|
|
||||||
**Interaction**
|
|
||||||
- [ ] Hover states are defined for all interactive elements
|
|
||||||
- [ ] Focus states are visible and clear
|
|
||||||
- [ ] Loading states prevent user confusion
|
|
||||||
- [ ] Success/error feedback is immediate
|
|
||||||
- [ ] Animations are smooth and purposeful
|
|
||||||
|
|
||||||
**Responsive**
|
|
||||||
- [ ] Layout adapts to mobile, tablet, desktop
|
|
||||||
- [ ] Touch targets are minimum 44px on mobile
|
|
||||||
- [ ] Text remains readable at all sizes
|
|
||||||
- [ ] No horizontal scrolling (except intentional)
|
|
||||||
|
|
||||||
**Accessibility**
|
|
||||||
- [ ] Keyboard navigation works completely
|
|
||||||
- [ ] Focus indicators are always visible
|
|
||||||
- [ ] Color contrast meets WCAG AA minimum
|
|
||||||
- [ ] ARIA labels present where needed
|
|
||||||
- [ ] Screen reader tested for critical flows
|
|
||||||
|
|
||||||
**Data**
|
|
||||||
- [ ] Empty states are handled gracefully
|
|
||||||
- [ ] Error states provide actionable guidance
|
|
||||||
- [ ] Large datasets perform well
|
|
||||||
- [ ] Loading states prevent layout shift
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference Assets
|
|
||||||
|
|
||||||
### Suggested Icon Library
|
|
||||||
- **Lucide Icons**: Clean, consistent, extensive
|
|
||||||
- **Heroicons**: Tailwind-friendly, well-designed
|
|
||||||
- **Phosphor Icons**: Flexible weights and styles
|
|
||||||
|
|
||||||
### Font Resources
|
|
||||||
- **Inter**: [Google Fonts](https://fonts.google.com/specimen/Inter)
|
|
||||||
- **Space Grotesk**: [Google Fonts](https://fonts.google.com/specimen/Space+Grotesk)
|
|
||||||
- **JetBrains Mono**: [JetBrains](https://www.jetbrains.com/lp/mono/)
|
|
||||||
|
|
||||||
### Design Tools
|
|
||||||
- Use component libraries: shadcn/ui, Headless UI, Radix
|
|
||||||
- Tailwind CSS for utility-first styling
|
|
||||||
- CSS variables for theming
|
|
||||||
- Design tokens for consistency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The Aurora Admin Panel is a sophisticated tool that demands respect through its design. Every pixel serves a purpose—whether to inform, to guide, or to reinforce the prestige of the academy it administers.
|
|
||||||
|
|
||||||
**Design with authority. Build with precision. Maintain the standard.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*These design guidelines are living documentation. As Aurora evolves, so too should these standards. Propose updates through the standard development workflow.*
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Feature Flag System
|
|
||||||
|
|
||||||
The feature flag system enables controlled beta testing of new features in production without requiring a separate test environment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Feature flags allow you to:
|
|
||||||
- Test new features with a limited audience before full rollout
|
|
||||||
- Enable/disable features without code changes or redeployment
|
|
||||||
- Control access per guild, user, or role
|
|
||||||
- Eliminate environment drift between test and production
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**`feature_flags` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | serial | Primary key |
|
|
||||||
| `name` | varchar(100) | Unique flag identifier |
|
|
||||||
| `enabled` | boolean | Whether the flag is active |
|
|
||||||
| `description` | text | Human-readable description |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
| `updated_at` | timestamp | Last update time |
|
|
||||||
|
|
||||||
**`feature_flag_access` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `id` | serial | Primary key |
|
|
||||||
| `flag_id` | integer | References feature_flags.id |
|
|
||||||
| `guild_id` | bigint | Guild whitelist (nullable) |
|
|
||||||
| `user_id` | bigint | User whitelist (nullable) |
|
|
||||||
| `role_id` | bigint | Role whitelist (nullable) |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
The `featureFlagsService` (`shared/modules/feature-flags/feature-flags.service.ts`) provides:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Check if a flag is globally enabled
|
|
||||||
await featureFlagsService.isFlagEnabled("trading_system");
|
|
||||||
|
|
||||||
// Check if a user has access to a flagged feature
|
|
||||||
await featureFlagsService.hasAccess("trading_system", {
|
|
||||||
guildId: "123456789",
|
|
||||||
userId: "987654321",
|
|
||||||
memberRoles: ["role1", "role2"]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new feature flag
|
|
||||||
await featureFlagsService.createFlag("new_feature", "Description");
|
|
||||||
|
|
||||||
// Enable/disable a flag
|
|
||||||
await featureFlagsService.setFlagEnabled("new_feature", true);
|
|
||||||
|
|
||||||
// Grant access to users/roles/guilds
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { userId: "123" });
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { roleId: "456" });
|
|
||||||
await featureFlagsService.grantAccess("new_feature", { guildId: "789" });
|
|
||||||
|
|
||||||
// List all flags or access records
|
|
||||||
await featureFlagsService.listFlags();
|
|
||||||
await featureFlagsService.listAccess("new_feature");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Marking a Command as Beta
|
|
||||||
|
|
||||||
Add `beta: true` to any command definition:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const newFeature = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("newfeature")
|
|
||||||
.setDescription("A new experimental feature"),
|
|
||||||
beta: true, // Marks this command as a beta feature
|
|
||||||
execute: async (interaction) => {
|
|
||||||
// Implementation
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
By default, the command name is used as the feature flag name. To use a custom flag name:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const trade = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("trade")
|
|
||||||
.setDescription("Trade items with another user"),
|
|
||||||
beta: true,
|
|
||||||
featureFlag: "trading_system", // Custom flag name
|
|
||||||
execute: async (interaction) => {
|
|
||||||
// Implementation
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Access Control Flow
|
|
||||||
|
|
||||||
When a user attempts to use a beta command:
|
|
||||||
|
|
||||||
1. **Check if flag exists and is enabled** - Returns false if flag doesn't exist or is disabled
|
|
||||||
2. **Check guild whitelist** - User's guild has access if `guild_id` matches
|
|
||||||
3. **Check user whitelist** - User has access if `user_id` matches
|
|
||||||
4. **Check role whitelist** - User has access if any of their roles match
|
|
||||||
|
|
||||||
If none of these conditions are met, the user sees:
|
|
||||||
> **Beta Feature**
|
|
||||||
> This feature is currently in beta testing and not available to all users. Stay tuned for the official release!
|
|
||||||
|
|
||||||
## Admin Commands
|
|
||||||
|
|
||||||
The `/featureflags` command (Administrator only) provides full management:
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/featureflags list` | List all feature flags with status |
|
|
||||||
| `/featureflags create <name> [description]` | Create a new flag (disabled by default) |
|
|
||||||
| `/featureflags delete <name>` | Delete a flag and all access records |
|
|
||||||
| `/featureflags enable <name>` | Enable a flag globally |
|
|
||||||
| `/featureflags disable <name>` | Disable a flag globally |
|
|
||||||
| `/featureflags grant <name> [user\|role]` | Grant access to a user or role |
|
|
||||||
| `/featureflags revoke <id>` | Revoke access by record ID |
|
|
||||||
| `/featureflags access <name>` | List all access records for a flag |
|
|
||||||
|
|
||||||
### Example Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Create the flag:
|
|
||||||
/featureflags create trading_system "Item trading between users"
|
|
||||||
|
|
||||||
2. Grant access to beta testers:
|
|
||||||
/featureflags grant trading_system user:@beta_tester
|
|
||||||
/featureflags grant trading_system role:@Beta Testers
|
|
||||||
|
|
||||||
3. Enable the flag:
|
|
||||||
/featureflags enable trading_system
|
|
||||||
|
|
||||||
4. View access list:
|
|
||||||
/featureflags access trading_system
|
|
||||||
|
|
||||||
5. When ready for full release:
|
|
||||||
- Remove beta: true from the command
|
|
||||||
- Delete the flag: /featureflags delete trading_system
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Descriptive Names**: Use snake_case names that clearly describe the feature
|
|
||||||
2. **Document Flags**: Always add a description when creating flags
|
|
||||||
3. **Role-Based Access**: Prefer role-based access over user-based for easier management
|
|
||||||
4. **Clean Up**: Delete flags after features are fully released
|
|
||||||
5. **Testing**: Always test with a small group before wider rollout
|
|
||||||
|
|
||||||
## Implementation Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `shared/db/schema/feature-flags.ts` | Database schema |
|
|
||||||
| `shared/modules/feature-flags/feature-flags.service.ts` | Service layer |
|
|
||||||
| `shared/lib/types.ts` | Command interface with beta properties |
|
|
||||||
| `bot/lib/handlers/CommandHandler.ts` | Beta access check |
|
|
||||||
| `bot/commands/admin/featureflags.ts` | Admin command |
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Guild Settings System
|
|
||||||
|
|
||||||
The guild settings system enables per-guild configuration stored in the database, eliminating environment-specific config files and enabling runtime updates without redeployment.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Guild settings allow you to:
|
|
||||||
- Store per-guild configuration in the database
|
|
||||||
- Update settings at runtime without code changes
|
|
||||||
- Support multiple guilds with different configurations
|
|
||||||
- Maintain backward compatibility with file-based config
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**`guild_settings` table:**
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `guild_id` | bigint | Primary key (Discord guild ID) |
|
|
||||||
| `student_role_id` | bigint | Student role ID |
|
|
||||||
| `visitor_role_id` | bigint | Visitor role ID |
|
|
||||||
| `color_role_ids` | jsonb | Array of color role IDs |
|
|
||||||
| `welcome_channel_id` | bigint | Welcome message channel |
|
|
||||||
| `welcome_message` | text | Custom welcome message |
|
|
||||||
| `feedback_channel_id` | bigint | Feedback channel |
|
|
||||||
| `terminal_channel_id` | bigint | Terminal channel |
|
|
||||||
| `terminal_message_id` | bigint | Terminal message ID |
|
|
||||||
| `moderation_log_channel_id` | bigint | Moderation log channel |
|
|
||||||
| `moderation_dm_on_warn` | jsonb | DM user on warn |
|
|
||||||
| `moderation_auto_timeout_threshold` | jsonb | Auto timeout after N warnings |
|
|
||||||
| `feature_overrides` | jsonb | Feature flag overrides |
|
|
||||||
| `created_at` | timestamp | Creation time |
|
|
||||||
| `updated_at` | timestamp | Last update time |
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
The `guildSettingsService` (`shared/modules/guild-settings/guild-settings.service.ts`) provides:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get settings for a guild (returns null if not configured)
|
|
||||||
await guildSettingsService.getSettings(guildId);
|
|
||||||
|
|
||||||
// Create or update settings
|
|
||||||
await guildSettingsService.upsertSettings({
|
|
||||||
guildId: "123456789",
|
|
||||||
studentRoleId: "987654321",
|
|
||||||
visitorRoleId: "111222333",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update a single setting
|
|
||||||
await guildSettingsService.updateSetting(guildId, "welcomeChannel", "456789123");
|
|
||||||
|
|
||||||
// Delete all settings for a guild
|
|
||||||
await guildSettingsService.deleteSettings(guildId);
|
|
||||||
|
|
||||||
// Color role helpers
|
|
||||||
await guildSettingsService.addColorRole(guildId, roleId);
|
|
||||||
await guildSettingsService.removeColorRole(guildId, roleId);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Getting Guild Configuration
|
|
||||||
|
|
||||||
Use `getGuildConfig()` instead of direct `config` imports for guild-specific settings:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
|
||||||
|
|
||||||
// In a command or interaction
|
|
||||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
|
||||||
|
|
||||||
// Access settings
|
|
||||||
const studentRole = guildConfig.studentRole;
|
|
||||||
const welcomeChannel = guildConfig.welcomeChannelId;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fallback Behavior
|
|
||||||
|
|
||||||
`getGuildConfig()` returns settings in this order:
|
|
||||||
1. **Database settings** (if guild is configured in DB)
|
|
||||||
2. **File config fallback** (during migration period)
|
|
||||||
|
|
||||||
This ensures backward compatibility while migrating from file-based config.
|
|
||||||
|
|
||||||
### Cache Invalidation
|
|
||||||
|
|
||||||
Settings are cached for 60 seconds. After updating settings, invalidate the cache:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
|
||||||
|
|
||||||
await guildSettingsService.upsertSettings({ guildId, ...settings });
|
|
||||||
invalidateGuildConfigCache(guildId);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Admin Commands
|
|
||||||
|
|
||||||
The `/settings` command (Administrator only) provides full management:
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/settings show` | Display current guild settings |
|
|
||||||
| `/settings set <key> [value]` | Update a setting |
|
|
||||||
| `/settings reset <key>` | Reset a setting to default |
|
|
||||||
| `/settings colors <action> [role]` | Manage color roles |
|
|
||||||
|
|
||||||
### Settable Keys
|
|
||||||
|
|
||||||
| Key | Type | Description |
|
|
||||||
|-----|------|-------------|
|
|
||||||
| `studentRole` | Role | Role for enrolled students |
|
|
||||||
| `visitorRole` | Role | Role for visitors |
|
|
||||||
| `welcomeChannel` | Channel | Channel for welcome messages |
|
|
||||||
| `welcomeMessage` | Text | Custom welcome message |
|
|
||||||
| `feedbackChannel` | Channel | Channel for feedback |
|
|
||||||
| `terminalChannel` | Channel | Terminal channel |
|
|
||||||
| `terminalMessage` | Text | Terminal message ID |
|
|
||||||
| `moderationLogChannel` | Channel | Moderation log channel |
|
|
||||||
| `moderationDmOnWarn` | Boolean | DM users on warn |
|
|
||||||
| `moderationAutoTimeoutThreshold` | Number | Auto timeout threshold |
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | `/api/guilds/:guildId/settings` | Get guild settings |
|
|
||||||
| PUT | `/api/guilds/:guildId/settings` | Create/replace settings |
|
|
||||||
| PATCH | `/api/guilds/:guildId/settings` | Partial update |
|
|
||||||
| DELETE | `/api/guilds/:guildId/settings` | Delete settings |
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
To migrate existing config.json settings to the database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:migrate-config
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Read values from `config.json`
|
|
||||||
2. Create a database record for `DISCORD_GUILD_ID`
|
|
||||||
3. Store all guild-specific settings
|
|
||||||
|
|
||||||
## Migration Strategy for Code
|
|
||||||
|
|
||||||
Update code references incrementally:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
const role = config.studentRole;
|
|
||||||
|
|
||||||
// After
|
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
|
||||||
const guildConfig = await getGuildConfig(guildId);
|
|
||||||
const role = guildConfig.studentRole;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Update
|
|
||||||
|
|
||||||
Files using guild-specific config that should be updated:
|
|
||||||
- `bot/events/guildMemberAdd.ts`
|
|
||||||
- `bot/modules/user/enrollment.interaction.ts`
|
|
||||||
- `bot/modules/feedback/feedback.interaction.ts`
|
|
||||||
- `bot/commands/feedback/feedback.ts`
|
|
||||||
- `bot/commands/inventory/use.ts`
|
|
||||||
- `bot/commands/admin/create_color.ts`
|
|
||||||
- `shared/modules/moderation/moderation.service.ts`
|
|
||||||
- `shared/modules/terminal/terminal.service.ts`
|
|
||||||
|
|
||||||
## Files Updated to Use Database Config
|
|
||||||
|
|
||||||
All code has been migrated to use `getGuildConfig()`:
|
|
||||||
|
|
||||||
- `bot/events/guildMemberAdd.ts` - Role assignment on join
|
|
||||||
- `bot/modules/user/enrollment.interaction.ts` - Enrollment flow
|
|
||||||
- `bot/modules/feedback/feedback.interaction.ts` - Feedback submission
|
|
||||||
- `bot/commands/feedback/feedback.ts` - Feedback command
|
|
||||||
- `bot/commands/inventory/use.ts` - Color role handling
|
|
||||||
- `bot/commands/admin/create_color.ts` - Color role creation
|
|
||||||
- `bot/commands/admin/warn.ts` - Warning with DM and auto-timeout
|
|
||||||
- `shared/modules/moderation/moderation.service.ts` - Accepts config param
|
|
||||||
- `shared/modules/terminal/terminal.service.ts` - Terminal location persistence
|
|
||||||
- `shared/modules/economy/lootdrop.service.ts` - Terminal updates
|
|
||||||
|
|
||||||
## Implementation Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `shared/db/schema/guild-settings.ts` | Database schema |
|
|
||||||
| `shared/modules/guild-settings/guild-settings.service.ts` | Service layer |
|
|
||||||
| `shared/lib/config.ts` | Config loader with getGuildConfig() |
|
|
||||||
| `bot/commands/admin/settings.ts` | Admin command |
|
|
||||||
| `web/src/routes/guild-settings.routes.ts` | API routes |
|
|
||||||
| `shared/scripts/migrate-config-to-db.ts` | Migration script |
|
|
||||||
162
docs/main.md
@@ -1,162 +0,0 @@
|
|||||||
# Aurora - Discord RPG Bot
|
|
||||||
|
|
||||||
A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and REST API in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
|
||||||
|
|
||||||
## Monorepo Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
aurora-bot-discord/
|
|
||||||
├── bot/ # Discord bot implementation
|
|
||||||
│ ├── commands/ # Slash command implementations
|
|
||||||
│ ├── events/ # Discord event handlers
|
|
||||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
|
||||||
│ └── index.ts # Bot entry point
|
|
||||||
├── web/ # REST API server
|
|
||||||
│ └── src/routes/ # API route handlers
|
|
||||||
├── shared/ # Shared code between bot and web
|
|
||||||
│ ├── db/ # Database schema and Drizzle ORM
|
|
||||||
│ ├── lib/ # Utilities, config, logger, events
|
|
||||||
│ ├── modules/ # Domain services (economy, admin, quest)
|
|
||||||
│ └── config/ # Configuration files
|
|
||||||
├── docker-compose.yml # Docker services (app, db)
|
|
||||||
└── package.json # Root package manifest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Main Application Parts
|
|
||||||
|
|
||||||
### 1. Discord Bot (`bot/`)
|
|
||||||
|
|
||||||
The bot is built with Discord.js v14 and handles all Discord-related functionality.
|
|
||||||
|
|
||||||
**Core Components:**
|
|
||||||
|
|
||||||
- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions
|
|
||||||
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
|
|
||||||
- `admin/`: Server management commands (config, prune, warnings, notes)
|
|
||||||
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
|
||||||
- `inventory/`: Item management commands
|
|
||||||
- `leveling/`: XP and level tracking
|
|
||||||
- `quest/`: Quest commands
|
|
||||||
- `user/`: User profile commands
|
|
||||||
- **Events** (`bot/events/`): Discord event handlers:
|
|
||||||
- `interactionCreate.ts`: Command interactions
|
|
||||||
- `messageCreate.ts`: Message processing
|
|
||||||
- `ready.ts`: Bot ready events
|
|
||||||
- `guildMemberAdd.ts`: New member handling
|
|
||||||
|
|
||||||
### 2. REST API (`web/`)
|
|
||||||
|
|
||||||
A headless REST API built with Bun's native HTTP server for bot administration and data access.
|
|
||||||
|
|
||||||
**Key Endpoints:**
|
|
||||||
|
|
||||||
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
|
|
||||||
- **Settings** (`/api/settings`): Configuration management endpoints
|
|
||||||
- **Users** (`/api/users`): User data and profiles
|
|
||||||
- **Items** (`/api/items`): Item catalog and management
|
|
||||||
- **Quests** (`/api/quests`): Quest data and progress
|
|
||||||
- **Economy** (`/api/transactions`): Economy and transaction data
|
|
||||||
|
|
||||||
**API Features:**
|
|
||||||
|
|
||||||
- Built with Bun's native HTTP server
|
|
||||||
- WebSocket support for real-time updates (`/ws`)
|
|
||||||
- REST API endpoints for all bot data
|
|
||||||
- Real-time event streaming via WebSocket
|
|
||||||
- Zod validation for all requests
|
|
||||||
|
|
||||||
### 3. Shared Core (`shared/`)
|
|
||||||
|
|
||||||
Shared code accessible by both bot and web applications.
|
|
||||||
|
|
||||||
**Database Layer (`shared/db/`):**
|
|
||||||
|
|
||||||
- **schema.ts**: Drizzle ORM schema definitions for:
|
|
||||||
- `users`: User profiles with economy data
|
|
||||||
- `items`: Item catalog with rarities and types
|
|
||||||
- `inventory`: User item holdings
|
|
||||||
- `transactions`: Economy transaction history
|
|
||||||
- `classes`: RPG class system
|
|
||||||
- `moderationCases`: Moderation logs
|
|
||||||
- `quests`: Quest definitions
|
|
||||||
|
|
||||||
**Modules (`shared/modules/`):**
|
|
||||||
|
|
||||||
- **economy/**: Economy service, lootdrops, daily rewards, trading
|
|
||||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
|
||||||
- **quest/**: Quest creation and tracking
|
|
||||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
|
||||||
- **leveling/**: XP and leveling logic
|
|
||||||
|
|
||||||
**Utilities (`shared/lib/`):**
|
|
||||||
|
|
||||||
- `config.ts`: Application configuration management
|
|
||||||
- `logger.ts`: Structured logging system
|
|
||||||
- `env.ts`: Environment variable handling
|
|
||||||
- `events.ts`: Event bus for inter-module communication
|
|
||||||
- `constants.ts`: Application-wide constants
|
|
||||||
|
|
||||||
## Main Use-Cases
|
|
||||||
|
|
||||||
### For Discord Users
|
|
||||||
|
|
||||||
1. **Class System**: Users can join different RPG classes with unique roles
|
|
||||||
2. **Economy**:
|
|
||||||
- View balance and net worth
|
|
||||||
- Earn currency through daily rewards, trivia, and lootdrops
|
|
||||||
- Send payments to other users
|
|
||||||
3. **Trading**: Secure trading system between users
|
|
||||||
4. **Inventory Management**: Collect, use, and trade items with rarities
|
|
||||||
5. **Leveling**: XP-based progression system tied to activity
|
|
||||||
6. **Quests**: Complete quests for rewards
|
|
||||||
7. **Lootdrops**: Random currency drops in text channels
|
|
||||||
|
|
||||||
### For Server Administrators
|
|
||||||
|
|
||||||
1. **Bot Configuration**: Adjust economy rates, enable/disable features via API
|
|
||||||
2. **Moderation Tools**:
|
|
||||||
- Warn, note, and track moderation cases
|
|
||||||
- Mass prune inactive members
|
|
||||||
- Role management
|
|
||||||
3. **Quest Management**: Create and manage server-specific quests
|
|
||||||
4. **Monitoring**:
|
|
||||||
- Real-time statistics via REST API
|
|
||||||
- Activity data and event logs
|
|
||||||
- Economy leaderboards
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
1. **Single Process Architecture**: Easy debugging with unified runtime
|
|
||||||
2. **Type Safety**: Full TypeScript across all modules
|
|
||||||
3. **Testing**: Bun test framework with unit tests for core services
|
|
||||||
4. **Docker Support**: Production-ready containerization
|
|
||||||
5. **Remote Access**: SSH tunneling scripts for production debugging
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
| ---------------- | --------------------------------- |
|
|
||||||
| Runtime | Bun 1.0+ |
|
|
||||||
| Bot Framework | Discord.js 14.x |
|
|
||||||
| Web Framework | Bun HTTP Server (REST API) |
|
|
||||||
| Database | PostgreSQL 17 |
|
|
||||||
| ORM | Drizzle ORM |
|
|
||||||
| UI | Discord embeds and components |
|
|
||||||
| Validation | Zod |
|
|
||||||
| Containerization | Docker |
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database migrations
|
|
||||||
bun run migrate
|
|
||||||
|
|
||||||
# Production (Docker)
|
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot and API server run on port 3000 and are accessible at `http://localhost:3000`.
|
|
||||||
60
docs/new-design/DESIGN.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Stellar Editorial
|
||||||
|
|
||||||
|
This document now tracks the design language that is actually implemented in the panel, with a small amount of forward-looking guidance where the code is still catching up.
|
||||||
|
|
||||||
|
## Current implementation status
|
||||||
|
|
||||||
|
Implemented today in `panel/src/index.css` and the active panel pages:
|
||||||
|
|
||||||
|
- dark "void and light" surface stack
|
||||||
|
- Celestial Gold primary color family
|
||||||
|
- Noto Serif, Manrope, Space Grotesk, and JetBrains Mono typography
|
||||||
|
- low-contrast "ghost border" treatment
|
||||||
|
- rounded surface hierarchy for cards, sidebars, and controls
|
||||||
|
|
||||||
|
Partially implemented or still aspirational:
|
||||||
|
|
||||||
|
- stronger asymmetry and editorial layouts across more pages
|
||||||
|
- decorative constellation/nebula treatments
|
||||||
|
- more consistent premium component states across every admin screen
|
||||||
|
|
||||||
|
## Design intent
|
||||||
|
|
||||||
|
Aurora's panel should feel like an elite astronomical academy rather than a default admin dashboard. The codebase already follows that direction through the theme tokens and typography system; new UI work should continue that tone instead of falling back to generic SaaS styling.
|
||||||
|
|
||||||
|
## Core tokens
|
||||||
|
|
||||||
|
Surface hierarchy:
|
||||||
|
|
||||||
|
- `--color-background`: `#0d1323`
|
||||||
|
- `--color-surface-container-low`: `#151b2c`
|
||||||
|
- `--color-surface-container-high`: `#24293b`
|
||||||
|
- `--color-surface-container-highest`: `#2f3446`
|
||||||
|
|
||||||
|
Primary accents:
|
||||||
|
|
||||||
|
- `--color-primary`: `#e9c349`
|
||||||
|
- `--color-primary-fixed-dim`: `#d4af37`
|
||||||
|
- `--color-primary-container`: `#3d2e00`
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
|
||||||
|
- display: Noto Serif
|
||||||
|
- body: Manrope
|
||||||
|
- labels: Space Grotesk
|
||||||
|
- mono: JetBrains Mono
|
||||||
|
|
||||||
|
## Component guidance
|
||||||
|
|
||||||
|
- Prefer tonal separation over heavy borders.
|
||||||
|
- Use gold as a focused accent, not a flood color.
|
||||||
|
- Keep text contrast high and metadata quieter.
|
||||||
|
- Sidebar, cards, tables, and game views should feel like the same product family.
|
||||||
|
- Avoid plain white cards, Discord blurple defaults, and generic component-library styling.
|
||||||
|
|
||||||
|
## Practical rules for future work
|
||||||
|
|
||||||
|
- Start from the CSS tokens in `panel/src/index.css` instead of inventing new one-off colors.
|
||||||
|
- Preserve the current font roles unless there is a strong reason to change them.
|
||||||
|
- Use gradients, glow, or tonal depth sparingly and intentionally.
|
||||||
|
- Keep mobile behavior first-class; the existing layout already has mobile drawer behavior that new pages should respect.
|
||||||
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "app",
|
||||||
"version": "1.1.4-pre",
|
"version": "1.1.5",
|
||||||
"module": "bot/index.ts",
|
"module": "bot/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
||||||
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
||||||
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
||||||
"test": "bash shared/scripts/test-sequential.sh",
|
"test": "bash shared/scripts/test-isolated.sh",
|
||||||
"test:ci": "bash shared/scripts/test-sequential.sh --integration",
|
"test:ci": "bash shared/scripts/test-isolated.sh --integration",
|
||||||
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
|
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
|
||||||
"panel:dev": "cd panel && bun run dev",
|
"panel:dev": "cd panel && bun run dev",
|
||||||
"panel:build": "cd panel && bun run build",
|
"panel:build": "cd panel && bun run build",
|
||||||
@@ -40,10 +40,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.89",
|
"@napi-rs/canvas": "^0.1.89",
|
||||||
|
"chess.js": "^1.4.0",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
panel/AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Panel
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 19
|
||||||
|
- React Router 7
|
||||||
|
- Vite 6
|
||||||
|
- Tailwind CSS v4 via `@tailwindcss/vite`
|
||||||
|
- Local utilities: `clsx`, `tailwind-merge`, `class-variance-authority`
|
||||||
|
- Icons: `lucide-react`
|
||||||
|
|
||||||
|
The panel lives in `panel/src` and is built to `panel/dist`.
|
||||||
|
|
||||||
|
## Dev and runtime
|
||||||
|
|
||||||
|
- `bun run panel:dev` starts Vite on `http://localhost:5173`
|
||||||
|
- Vite proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`
|
||||||
|
- The Bun server serves the built panel from `panel/dist` in integrated mode
|
||||||
|
|
||||||
|
## Auth model
|
||||||
|
|
||||||
|
- `useAuth()` calls `GET /auth/me`
|
||||||
|
- unauthenticated users are sent through `/auth/discord`
|
||||||
|
- logout uses `POST /auth/logout`
|
||||||
|
- non-enrolled users see the `NotEnrolled` page
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
|
||||||
|
- `admin`: admin routes plus player/game routes
|
||||||
|
- `player`: player/game routes only
|
||||||
|
|
||||||
|
## Active routes
|
||||||
|
|
||||||
|
- `/dashboard`
|
||||||
|
- `/leaderboards`
|
||||||
|
- `/games`
|
||||||
|
- `/:gameSlug/:roomId`
|
||||||
|
- `/admin`
|
||||||
|
- `/admin/users`
|
||||||
|
- `/admin/items`
|
||||||
|
- `/admin/classes`
|
||||||
|
- `/admin/quests`
|
||||||
|
- `/admin/lootdrops`
|
||||||
|
- `/admin/moderation`
|
||||||
|
- `/admin/transactions`
|
||||||
|
- `/admin/settings`
|
||||||
|
|
||||||
|
## Data layer
|
||||||
|
|
||||||
|
Shared hooks in `panel/src/lib`:
|
||||||
|
|
||||||
|
- `useAuth`
|
||||||
|
- `useDashboard`
|
||||||
|
- `useUsers`
|
||||||
|
- `useItems`
|
||||||
|
- `useSettings`
|
||||||
|
- `useWebSocket`
|
||||||
|
- `useGameRoom`
|
||||||
|
|
||||||
|
`panel/src/lib/api.ts` is a thin fetch wrapper:
|
||||||
|
|
||||||
|
- base path is empty because the panel is usually same-origin with the Bun server
|
||||||
|
- 401 triggers a redirect back into `/auth/discord`
|
||||||
|
- 204 and empty responses return `undefined`
|
||||||
|
|
||||||
|
## WebSocket and games
|
||||||
|
|
||||||
|
- `useWebSocket()` keeps a singleton browser WebSocket connection
|
||||||
|
- reconnects with exponential backoff up to 30 seconds
|
||||||
|
- `useGameRoom()` multiplexes room traffic over that shared socket
|
||||||
|
- current built-in game UIs are chess and blackjack
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- Theme tokens live in `panel/src/index.css`
|
||||||
|
- Fonts currently loaded:
|
||||||
|
- Noto Serif
|
||||||
|
- Manrope
|
||||||
|
- Space Grotesk
|
||||||
|
- JetBrains Mono
|
||||||
|
- The implemented visual system is the "Stellar Editorial" direction documented in `docs/new-design/DESIGN.md`
|
||||||
|
|
||||||
|
## Current patterns
|
||||||
|
|
||||||
|
- Admin pages tend to use explicit `refetch()` after mutations instead of a shared cache layer
|
||||||
|
- Search inputs use a 300 ms debounce in hooks such as `useUsers()` and `useItems()`
|
||||||
|
- Layout and sidebar ownership lives in `panel/src/components/Layout.tsx`
|
||||||
@@ -10,11 +10,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@imgly/background-removal": "^1.7.0",
|
"@imgly/background-removal": "^1.7.0",
|
||||||
|
"chess.js": "^1.4.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-chessboard": "^5.10.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
281
panel/public/cards/10_of_clubs.svg
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="10_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 57.572834,25.099947 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.8743954 -3.43972,-10.3065945 -11.392028,-10.3065945 -7.952308,0 -11.392028,6.4347116 -11.392028,10.3065945 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680956 0,5.16586 4.22113,10.849311 10.849311,10.849311 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.630693,0 10.849311,-5.685963 10.849311,-10.849311 0,-10.319157 -11.816654,-13.844304 -18.444833,-8.680956 z"
|
||||||
|
id="cl-9-8" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 57.110434,93.200747 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.874396 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434711 -11.392028,10.306594 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680953 0,5.16587 4.22113,10.84932 10.849311,10.84932 7.952308,0 11.392027,-8.68096 11.392027,-8.68096 0,0 1.010056,9.89453 -4.881939,15.19104 h 13.020178 c -5.891994,-5.294 -4.881938,-15.19104 -4.881938,-15.19104 0,0 3.439718,8.68096 11.392027,8.68096 6.630693,0 10.849311,-5.68597 10.849311,-10.84932 0,-10.319154 -11.816654,-13.844301 -18.444833,-8.680953 z"
|
||||||
|
id="cl-9-8-0" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 121.55789,24.926219 c 0,0 5.96737,-4.773898 5.96737,-11.392027 0,-3.8743954 -3.43971,-10.3065945 -11.39203,-10.3065945 -7.95231,0 -11.39202,6.4347116 -11.39202,10.3065945 0,6.618129 5.96737,11.392027 5.96737,11.392027 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680956 0,5.16586 4.22113,10.849311 10.849304,10.849311 7.95231,0 11.39203,-8.680956 11.39203,-8.680956 0,0 1.01006,9.894531 -4.88193,15.191045 h 13.02017 c -5.89199,-5.294001 -4.88193,-15.191045 -4.88193,-15.191045 0,0 3.43971,8.680956 11.39202,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.849311 0,-10.319157 -11.81665,-13.844304 -18.44483,-8.680956 z"
|
||||||
|
id="cl-9-8-9" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 121.55789,93.027019 c 0,0 5.96737,-4.773898 5.96737,-11.392028 0,-3.874395 -3.43971,-10.306593 -11.39203,-10.306593 -7.95231,0 -11.39202,6.434711 -11.39202,10.306593 0,6.61813 5.96737,11.392028 5.96737,11.392028 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680951 0,5.16587 4.22113,10.84932 10.849304,10.84932 7.95231,0 11.39203,-8.68096 11.39203,-8.68096 0,0 1.01006,9.89453 -4.88193,15.19104 h 13.02017 c -5.89199,-5.294 -4.88193,-15.19104 -4.88193,-15.19104 0,0 3.43971,8.68096 11.39202,8.68096 6.63069,0 10.84931,-5.68597 10.84931,-10.84932 0,-10.319152 -11.81665,-13.844299 -18.44483,-8.680951 z"
|
||||||
|
id="cl-9-8-0-4" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 89.576798,59.281103 c 0,0 5.967372,-4.773897 5.967372,-11.392027 0,-3.874395 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434712 -11.392028,10.306594 0,6.61813 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163347 -18.444833,-1.638201 -18.444833,8.680957 0,5.165859 4.22113,10.84931 10.849311,10.84931 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.84931 0,-10.319158 -11.816653,-13.844304 -18.444832,-8.680957 z"
|
||||||
|
id="cl-9-8-8" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 110.06258,217.80216 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
||||||
|
id="cl-9-8-4" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 110.70832,149.70136 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
||||||
|
id="cl-9-8-0-2" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 46.077633,217.97556 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 44.9922 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
||||||
|
id="cl-9-8-9-6" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 46.261118,149.87509 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 45.175685 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
||||||
|
id="cl-9-8-0-4-9" /><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-1.1621548"
|
||||||
|
y="27.170401"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="-1.1621548"
|
||||||
|
y="27.170401"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="11.000458"
|
||||||
|
y="27.499109"
|
||||||
|
id="text3038"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040"
|
||||||
|
x="11.000458"
|
||||||
|
y="27.499109">0</tspan></text>
|
||||||
|
<path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 78.0698,183.9376 c 0,0 -5.96738,4.77389 -5.96738,11.39202 0,3.8744 3.43972,10.3066 11.39203,10.3066 7.95231,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61813 -5.96737,-11.39202 -5.96737,-11.39202 6.62818,5.16334 18.44483,1.6382 18.44483,-8.68096 0,-5.16586 -4.22113,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39203,8.68096 -11.39203,8.68096 0,0 -1.01005,-9.89454 4.88194,-15.19105 H 76.98436 c 5.892,5.294 4.88194,15.19105 4.88194,15.19105 0,0 -3.43972,-8.68096 -11.39203,-8.68096 -6.630688,0 -10.849308,5.68596 -10.849308,10.84931 0,10.31916 11.816658,13.8443 18.444838,8.68096 z"
|
||||||
|
id="cl-9-8-8-8" /><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-168.80901"
|
||||||
|
y="-216.22618"
|
||||||
|
id="text3788-0"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-168.80901"
|
||||||
|
y="-216.22618"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-156.64639"
|
||||||
|
y="-215.89748"
|
||||||
|
id="text3038-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040-9"
|
||||||
|
x="-156.64639"
|
||||||
|
y="-215.89748">0</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
401
panel/public/cards/10_of_diamonds.svg
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="10_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="0.4075976"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="0.4075976"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,210.91474)"
|
||||||
|
id="layer1-2-6-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,31.619539)"
|
||||||
|
id="layer1-2-6-8-2-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,151.18274)"
|
||||||
|
id="layer1-2-6-8-2-8-1"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,91.351539)"
|
||||||
|
id="layer1-2-6-8-2-8-1-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.89593,210.91474)"
|
||||||
|
id="layer1-2-6-8-8-9"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8-4"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.89593,31.619552)"
|
||||||
|
id="layer1-2-6-8-2-4-9"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3-0"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.89593,151.18274)"
|
||||||
|
id="layer1-2-6-8-2-8-1-9"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4-1"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.89593,91.351542)"
|
||||||
|
id="layer1-2-6-8-2-8-1-4-7"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4-9-7"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,83.213394,61.828949)"
|
||||||
|
id="layer1-2-6-8-2-4-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3-5"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,83.008034,180.83805)"
|
||||||
|
id="layer1-2-6-8-2-4-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3-5-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="13.216442"
|
||||||
|
y="26.376137"
|
||||||
|
id="text3788-43"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-1"
|
||||||
|
x="13.216442"
|
||||||
|
y="26.376137"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">0</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-166.43544"
|
||||||
|
y="-215.98416"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-9"
|
||||||
|
x="-166.43544"
|
||||||
|
y="-215.98416"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-153.62659"
|
||||||
|
y="-216.0213"
|
||||||
|
id="text3788-43-2"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-1-0"
|
||||||
|
x="-153.62659"
|
||||||
|
y="-216.0213"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">0</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
407
panel/public/cards/10_of_hearts.svg
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="10_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-128.9357"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-0.98704022"
|
||||||
|
y="29.202564"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="-0.98704022"
|
||||||
|
y="29.202564"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,54.512268,30.003768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,54.512268,212.80617)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,54.512268,95.238623)"
|
||||||
|
id="layer1-9-6-8-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,54.512268,145.35601)"
|
||||||
|
id="layer1-9-6-8-884-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-3-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,111.99088,30.602538)"
|
||||||
|
id="layer1-9-6-8-7"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,111.99088,213.40494)"
|
||||||
|
id="layer1-9-6-8-9-7"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5-2"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,111.99088,95.837397)"
|
||||||
|
id="layer1-9-6-8-8-7"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8-2"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,111.99088,145.95478)"
|
||||||
|
id="layer1-9-6-8-884-8-2"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-3-8-6"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,83.213391,62.704546)"
|
||||||
|
id="layer1-9-6-8-91"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-7"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,82.748515,179.70293)"
|
||||||
|
id="layer1-9-6-8-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="12.28669"
|
||||||
|
y="28.960051"
|
||||||
|
id="text3788-43"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-1"
|
||||||
|
x="12.28669"
|
||||||
|
y="28.960051"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">0</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-167.59764"
|
||||||
|
y="-212.05972"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-5"
|
||||||
|
x="-167.59764"
|
||||||
|
y="-212.05972"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-154.32391"
|
||||||
|
y="-212.30225"
|
||||||
|
id="text3788-43-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-1-7"
|
||||||
|
x="-154.32391"
|
||||||
|
y="-212.30225"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">0</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
230
panel/public/cards/10_of_spades.svg
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="10_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="0.043596052"
|
||||||
|
y="28.342009"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="0.043596052"
|
||||||
|
y="28.342009"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929041,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,53.01089,31.765995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,53.339713,94.698498)"
|
||||||
|
id="layer1-7-88-8"><path
|
||||||
|
id="sl-4-8"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,53.789261,212.66394)"
|
||||||
|
id="layer1-7-88-4"><path
|
||||||
|
id="sl-4-3"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,53.460438,151.33144)"
|
||||||
|
id="layer1-7-88-8-1"><path
|
||||||
|
id="sl-4-8-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,114.97822,31.578035)"
|
||||||
|
id="layer1-7-88-9"><path
|
||||||
|
id="sl-4-2"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,115.30704,94.510535)"
|
||||||
|
id="layer1-7-88-8-0"><path
|
||||||
|
id="sl-4-8-6"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,115.75659,212.47597)"
|
||||||
|
id="layer1-7-88-4-8"><path
|
||||||
|
id="sl-4-3-9"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,115.42777,151.14347)"
|
||||||
|
id="layer1-7-88-8-1-2"><path
|
||||||
|
id="sl-4-8-4-6"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,84.152135,64.171198)"
|
||||||
|
id="layer1-7-88-6"><path
|
||||||
|
id="sl-4-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,84.480868,180.54025)"
|
||||||
|
id="layer1-7-88-6-6"><path
|
||||||
|
id="sl-4-4-8"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="12.206209"
|
||||||
|
y="28.670717"
|
||||||
|
id="text3038"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040"
|
||||||
|
x="12.206209"
|
||||||
|
y="28.670717">0</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-166.83667"
|
||||||
|
y="-214.58258"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-166.83667"
|
||||||
|
y="-214.58258"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-154.67406"
|
||||||
|
y="-214.25388"
|
||||||
|
id="text3038-1"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040-4"
|
||||||
|
x="-154.67406"
|
||||||
|
y="-214.25388">0</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
216
panel/public/cards/2_of_clubs.svg
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="2_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 8.4 KiB |
318
panel/public/cards/2_of_diamonds.svg
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="2_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><filter
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="filter3834-6-0"
|
||||||
|
x="-0.13934441"
|
||||||
|
width="1.2786888"
|
||||||
|
y="-0.16242018"
|
||||||
|
height="1.3248404"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="9.5105772"
|
||||||
|
id="feGaussianBlur3836-6-6" /></filter><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.928726,184.02194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.928726,55.619539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
308
panel/public/cards/2_of_hearts.svg
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="2_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-28.405554"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,83.701068,56.859768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,83.701068,185.26217)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
147
panel/public/cards/2_of_spades.svg
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="2_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929041,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,83.41089,57.365995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,83.45613,185.77132)"
|
||||||
|
id="layer1-7-88-7"><path
|
||||||
|
id="sl-4-5"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
224
panel/public/cards/3_of_clubs.svg
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="3_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-9.5311159)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,253.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,60.169684)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
319
panel/public/cards/3_of_diamonds.svg
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="3_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.928726,192.02194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.928726,50.819539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.928726,120.52034)"
|
||||||
|
id="layer1-2-6-8-2-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
318
panel/public/cards/3_of_hearts.svg
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="3_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-28.405554"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,83.312268,47.603768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,83.312268,192.00617)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,83.312268,118.90457)"
|
||||||
|
id="layer1-9-6-8-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
154
panel/public/cards/3_of_spades.svg
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="3_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929104,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,83.41089,49.365995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,83.45613,193.77132)"
|
||||||
|
id="layer1-7-88-7"><path
|
||||||
|
id="sl-4-5"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,83.494697,119.06732)"
|
||||||
|
id="layer1-7-88-6"><path
|
||||||
|
id="sl-4-8"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 6.3 KiB |
230
panel/public/cards/4_of_clubs.svg
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="4_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/4_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-67.188386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,174.72954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-9.1115857,-1.5311131)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-66"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,232.80634,245.27515)"
|
||||||
|
id="layer1-1-4-8-0-4"><path
|
||||||
|
id="cl-9-8-6-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 9.7 KiB |
324
panel/public/cards/4_of_diamonds.svg
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="4_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/4_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.20553,184.02194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.20553,55.619539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,184.02194)"
|
||||||
|
id="layer1-2-6-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,55.619539)"
|
||||||
|
id="layer1-2-6-8-2-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
335
panel/public/cards/4_of_hearts.svg
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="4_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/4_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-28.405554"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,56.112268,64.859768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,56.112268,177.26217)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,110.70455,65.1323)"
|
||||||
|
id="layer1-9-6-8-0"
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/layer1-9-6-8-9-8.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-6"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,110.70455,177.53469)"
|
||||||
|
id="layer1-9-6-8-9-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5-9"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
163
panel/public/cards/4_of_spades.svg
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="4_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/4_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929041,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,54.61089,57.365995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,54.65613,185.77132)"
|
||||||
|
id="layer1-7-88-7"><path
|
||||||
|
id="sl-4-5"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,112.688,57.35436)"
|
||||||
|
id="layer1-7-88-1"><path
|
||||||
|
id="sl-4-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,112.73324,185.75969)"
|
||||||
|
id="layer1-7-88-7-9"><path
|
||||||
|
id="sl-4-5-2"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
238
panel/public/cards/5_of_clubs.svg
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="5_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/5_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-67.188386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,174.72954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-9.1115857,-1.5311131)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-66"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,232.80634,245.27515)"
|
||||||
|
id="layer1-1-4-8-0-4"><path
|
||||||
|
id="cl-9-8-6-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-38.388386,61.769684)"
|
||||||
|
id="layer1-1-4-8-2-6"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
333
panel/public/cards/5_of_diamonds.svg
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="5_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/5_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.20553,184.02194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,112.20553,55.619539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,184.02194)"
|
||||||
|
id="layer1-2-6-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.128726,55.619539)"
|
||||||
|
id="layer1-2-6-8-2-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,82.283636,119.47398)"
|
||||||
|
id="layer1-2-6-8-2-4-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
336
panel/public/cards/5_of_hearts.svg
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="5_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/5_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-28.405554"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,56.112268,64.859768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,56.112268,177.26217)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,110.70455,65.1323)"
|
||||||
|
id="layer1-9-6-8-0"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-6"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,110.70455,177.53469)"
|
||||||
|
id="layer1-9-6-8-9-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5-9"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,83.213395,125.05253)"
|
||||||
|
id="layer1-9-6-8-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
170
panel/public/cards/5_of_spades.svg
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="5_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/5_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929104,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,56.21089,49.365995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,56.25613,193.77132)"
|
||||||
|
id="layer1-7-88-7"><path
|
||||||
|
id="sl-4-5"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,83.494697,119.06732)"
|
||||||
|
id="layer1-7-88-6"><path
|
||||||
|
id="sl-4-8"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,109.93095,49.495625)"
|
||||||
|
id="layer1-7-88-8"><path
|
||||||
|
id="sl-4-9"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,109.97619,193.90095)"
|
||||||
|
id="layer1-7-88-7-2"><path
|
||||||
|
id="sl-4-5-6"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
244
panel/public/cards/6_of_clubs.svg
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="6_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/6_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,-9.5311159)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,177.92954,253.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,60.169684)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,-9.7048439)"
|
||||||
|
id="layer1-1-4-8-8"><path
|
||||||
|
id="cl-9-8-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,230.7146,253.10142)"
|
||||||
|
id="layer1-1-4-8-0-2"><path
|
||||||
|
id="cl-9-8-6-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,59.995956)"
|
||||||
|
id="layer1-1-4-8-2-6"><path
|
||||||
|
id="cl-9-8-0-4"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
340
panel/public/cards/6_of_diamonds.svg
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="6_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/6_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,110.12873,192.02194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,110.12873,50.819539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,110.12873,120.52034)"
|
||||||
|
id="layer1-2-6-8-2-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,56.013391,192.14005)"
|
||||||
|
id="layer1-2-6-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,56.013391,50.937663)"
|
||||||
|
id="layer1-2-6-8-2-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,56.013391,120.63845)"
|
||||||
|
id="layer1-2-6-8-2-8-1"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
344
panel/public/cards/6_of_hearts.svg
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="6_of_hearts.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/6_of_hearts.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3768"
|
||||||
|
id="radialGradient3776"
|
||||||
|
cx="-0.20602037"
|
||||||
|
cy="-4.5786963"
|
||||||
|
fx="-0.20602037"
|
||||||
|
fy="-4.5786963"
|
||||||
|
r="8"
|
||||||
|
gradientTransform="matrix(-1,0,0,-1.7201755,-0.41204074,-13.027194)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013"
|
||||||
|
xlink:href="#linearGradient3784-4-6"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3073"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="-28.405554"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.775425"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.6743072,0,0,1.5669921,17.177511,46.385321)"
|
||||||
|
id="layer1-9-6"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-158.81761"
|
||||||
|
y="-213.51517"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.6743072,0,0,-1.5669921,150.15601,195.14313)"
|
||||||
|
id="layer1-9-6-5"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,57.712268,47.603768)"
|
||||||
|
id="layer1-9-6-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,57.712268,192.00617)"
|
||||||
|
id="layer1-9-6-8-9"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,57.712268,118.90457)"
|
||||||
|
id="layer1-9-6-8-8"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,109.0504,47.272778)"
|
||||||
|
id="layer1-9-6-8-88"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-4"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(-2.7790082,0,0,-2.600887,109.0504,191.67518)"
|
||||||
|
id="layer1-9-6-8-9-3"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-5-1"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g><g
|
||||||
|
transform="matrix(2.7790082,0,0,2.600887,109.0504,118.57358)"
|
||||||
|
id="layer1-9-6-8-8-4"
|
||||||
|
style="fill:#df0000;fill-opacity:1"><path
|
||||||
|
style="fill:#df0000;fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="hl-8-8-8-9"
|
||||||
|
d="M 3.676,-9 C 0.433,-9 0,-5.523 0,-5.523 0,-5.523 -0.433,-9 -3.676,-9 -5.946,-9 -8,-7.441 -8,-4.5 -8,-0.614 -1.4208493,3.2938141 0,9 1.35201,3.2985969 8,-0.614 8,-4.5 8,-7.441 5.946,-9 3.676,-9 z"
|
||||||
|
sodipodi:nodetypes="scsscss" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
177
panel/public/cards/6_of_spades.svg
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="6_of_spades.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/6_of_spades.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41" /><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="106.02254"
|
||||||
|
inkscape:cy="157.08206"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.5467014"
|
||||||
|
y="28.013288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.5085945,0,0,1.3793253,16.929104,45.065897)"
|
||||||
|
id="layer1-7"><path
|
||||||
|
id="sl"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g>
|
||||||
|
<g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,56.21089,49.365995)"
|
||||||
|
id="layer1-7-88"><path
|
||||||
|
id="sl-4"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
id="text3788-7"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-158.97775"
|
||||||
|
y="-215.12402"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.5085945,0,0,-1.3793253,150.22511,198.04408)"
|
||||||
|
id="layer1-7-3"><path
|
||||||
|
id="sl-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,56.25613,193.77132)"
|
||||||
|
id="layer1-7-88-7"><path
|
||||||
|
id="sl-4-5"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,56.294697,119.06732)"
|
||||||
|
id="layer1-7-88-6"><path
|
||||||
|
id="sl-4-8"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,110.89781,49.166905)"
|
||||||
|
id="layer1-7-88-68"><path
|
||||||
|
id="sl-4-84"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(-2.6486789,0,0,-2.4217176,110.94305,193.57223)"
|
||||||
|
id="layer1-7-88-7-3"><path
|
||||||
|
id="sl-4-5-1"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g><g
|
||||||
|
transform="matrix(2.6486789,0,0,2.4217176,110.98162,118.86823)"
|
||||||
|
id="layer1-7-88-6-4"><path
|
||||||
|
id="sl-4-8-9"
|
||||||
|
d="M 7.989,3.103 C 7.747,-0.954 0.242,-8.59 0,-10.5 c -0.242,1.909 -7.747,9.545 -7.989,13.603 -0.169,2.868 1.695,4.057 3.39,4.057 1.8351685,-0.021581 3.3508701,-2.8006944 3.873,-3.341 0.242,0.716 -1.603,6.682 -2.179,6.682 l 5.811,0 C 2.33,10.501 0.485,4.535 0.727,3.819 1.1841472,4.3152961 2.5241276,7.0768295 4.601,7.16 6.295,7.159 8.158,5.971 7.989,3.103 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000"
|
||||||
|
sodipodi:nodetypes="cccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
252
panel/public/cards/7_of_clubs.svg
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="7_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/7_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">7</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">7</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,-27.131116)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,177.92954,269.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,63.369684)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,-27.304844)"
|
||||||
|
id="layer1-1-4-8-8"><path
|
||||||
|
id="cl-9-8-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,230.7146,269.10142)"
|
||||||
|
id="layer1-1-4-8-0-2"><path
|
||||||
|
id="cl-9-8-6-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,63.195956)"
|
||||||
|
id="layer1-1-4-8-2-6"><path
|
||||||
|
id="cl-9-8-0-4"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-38.055702,18.622356)"
|
||||||
|
id="layer1-1-4-8-6"><path
|
||||||
|
id="cl-9-8-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
349
panel/public/cards/7_of_diamonds.svg
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="7_of_diamonds.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/7_of_diamonds.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3781"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3773"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3775" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3777" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3773"
|
||||||
|
id="radialGradient3957"
|
||||||
|
cx="-0.15782039"
|
||||||
|
cy="-8.8345356"
|
||||||
|
fx="-0.15782039"
|
||||||
|
fy="-8.8345356"
|
||||||
|
r="7.9997029"
|
||||||
|
gradientTransform="matrix(-1.5842693,-0.02349808,0.03071979,-2.4775745,-0.24856378,-26.713507)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3959"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3961" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.64885497;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3963" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
cx="168.02475"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3975"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-5"
|
||||||
|
id="radialGradient3929"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-5"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.48854962;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-0" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-3" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784-4-1"
|
||||||
|
id="radialGradient3927"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.2565605,-0.77740644,0.33663816,0.5361257,-221.20213,359.24256)"
|
||||||
|
cx="168.02475"
|
||||||
|
cy="509.47577"
|
||||||
|
fx="168.02475"
|
||||||
|
fy="509.47577"
|
||||||
|
r="81.902771" /><linearGradient
|
||||||
|
id="linearGradient3784-4-1"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.23664123;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-03" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-6" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3768"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3770" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.67175573;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3772" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-6"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.31297711;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-8" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="492.63205"
|
||||||
|
fx="159.35434"
|
||||||
|
cy="492.63205"
|
||||||
|
cx="159.35434"
|
||||||
|
gradientTransform="matrix(1.0894779,-0.71513803,0.44645273,0.65626582,-244.93331,290.9185)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient4013-8"
|
||||||
|
xlink:href="#linearGradient3784-4-2"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4-2"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.29007635;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-1" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-5" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#df0000;stop-opacity:0.64122134;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient3784-4-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.4351145;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8-8-2" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6-1" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3100"
|
||||||
|
xlink:href="#linearGradient3784-4-4"
|
||||||
|
inkscape:collect="always" /><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3137"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.1224159,0.00551393,-0.00908973,-1.8503101,-0.0293938,-10.227695)"
|
||||||
|
cx="1.6632675e-13"
|
||||||
|
cy="-3.2337365"
|
||||||
|
fx="1.6632675e-13"
|
||||||
|
fy="-3.2337365"
|
||||||
|
r="8" /></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.7208768"
|
||||||
|
inkscape:cx="72.124594"
|
||||||
|
inkscape:cy="147.27218"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="7.8456664"
|
||||||
|
y="26.413288"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">7</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#df0000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
id="text3788-4"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-3"
|
||||||
|
x="-159.48785"
|
||||||
|
y="-216.71518"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#df0000;fill-opacity:1;font-family:Arial;-inkscape-font-specification:Arial">7</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,16.968095,44.236162)"
|
||||||
|
id="layer1-2-6"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(1.4769065,0,0,1.4769065,150.62089,198.50346)"
|
||||||
|
id="layer1-2-6-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-9"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,111.72873,193.62194)"
|
||||||
|
id="layer1-2-6-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,111.72873,49.219539)"
|
||||||
|
id="layer1-2-6-8-2"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,111.72873,120.52034)"
|
||||||
|
id="layer1-2-6-8-2-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.413391,193.74005)"
|
||||||
|
id="layer1-2-6-8-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.413391,49.337663)"
|
||||||
|
id="layer1-2-6-8-2-4"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,54.413391,120.63845)"
|
||||||
|
id="layer1-2-6-8-2-8-1"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-8-4"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g><g
|
||||||
|
transform="matrix(2.5882908,0,0,2.5882908,83.213393,85.53779)"
|
||||||
|
id="layer1-2-6-8-2-4-8"><path
|
||||||
|
style="fill:#df0000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="dl-6-8-6-3-8"
|
||||||
|
d="M 3.2433274,-4.7253274 C 1.1263274,-7.5893274 0,-10.5 0,-10.5 c 0,0 -1.1263274,2.9106726 -3.2433274,5.7746726 C -5.3613274,-1.8623274 -8,0 -8,0 -8,0 -5.3613274,1.8613274 -3.2433274,4.7263274 -1.1263274,7.5893274 0,10.5 0,10.5 0,10.5 1.1263274,7.5893274 3.2433274,4.7263274 5.3613274,1.8613274 8,0 8,0 8,0 5.3613274,-1.8623274 3.2433274,-4.7253274 z"
|
||||||
|
sodipodi:nodetypes="ccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |