diff --git a/.gitignore b/.gitignore index 6eeac3e..95fa88a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ fish.sublime-workspace coverage/ dist/ .vscode +.claude/ \ No newline at end of file diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..c12ea0f --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,142 @@ +# Code Issues Report + +This document lists identified bugs, security vulnerabilities, and code quality issues in the FISH codebase. + +## Summary + +| Severity | Count | +|----------|-------| +| Critical/High | 6 | +| Medium | 18 | +| Low | 6 | +| **Total** | **30** | + +--- + +## Critical / High Severity + +| # | Issue | Location | Description | +|---|-------|----------|-------------| +| 1 | **Memory Leak** | `src/engine/engine.js:31-78` | Socket event handlers never removed on disconnect | +| 3 | **Race Condition** | `src/engine/ocean-manager.js:44-67` | `hasRoom()` and `addFisher()` not atomic - can overfill oceans | +| 7 | **XSS Risk** | `public/js/fish.js`, `dashboard.js`, `microworld.js` | `.html()` used with unsanitized server data | +| 12 | **Hardcoded Secrets** | `src/app.js:98,101` | Session secret hardcoded: `'life is better under the sea'` | +| 20 | **Open Redirect** | `public/js/fish.js:588-596` | `ocean.redirectURL` used without validation | +| 24 | **Race Condition** | `src/engine/ocean-manager.js:73-122` | Ocean purging can delete oceans while events are in flight | + +--- + +## Medium Severity + +| # | Issue | Location | Description | +|---|-------|----------|-------------| +| 2 | Missing error handling | `src/engine/engine.js:82-95` | Admin socket has no error handling | +| 4 | Null dereference | `src/engine/ocean-manager.js:50,63` | `this.oceans[oId]` accessed without null check | +| 5 | Improper for-in loops | `src/engine/ocean.js` (13 locations) | `for (var i in array)` iterates all enumerable props | +| 6 | Uncaught JSON.parse | `public/js/admin.js:9`, `participant-access.js`, `dashboard.js`, `microworld.js` | No try-catch around JSON.parse | +| 8 | Missing null checks | `public/js/fish.js:340-395` | `fisher.seasonData[st.season]` may be undefined | +| 9 | Swallowed errors | `src/routes/experimenters.js:32,40,58-62` | Callback error parameter ignored (`_`) | +| 10 | Unhandled promise rejection | `src/routes/sessions.js:21,29-30,69,77-78` | Database operations missing error propagation | +| 13 | Stale room broadcasts | `src/engine/ocean.js` (11 locations) | Emitting to rooms without verifying ocean exists | +| 14 | Global variable pollution | `src/engine/ocean.js:8-9` | `io` and `ioAdmin` shared across all Ocean instances | +| 15 | Missing URL validation | `public/js/fish.js:4-8` | `mwId` and `pId` not validated | +| 16 | Masked errors | `src/engine/fisher.js:135-161` | Try-catch logs but swallows errors | +| 18 | Callback hell | `src/routes/microworlds.js:69-121`, `sessions.js` | `async.waterfall` with nested callbacks | +| 19 | Missing param validation | `src/routes/experimenters.js:50-64` | No validation before database insert | +| 21 | No input sanitization | `src/routes/microworlds.js:45-57` | User input stored directly in DB | +| 23 | Self-request pattern | `src/routes/experimenters.js:9-27` | Route makes HTTP request to itself | +| 25 | No DB error recovery | `src/engine/ocean.js:588-601` | `Run.create()` failure doesn't rollback state | +| 27 | Socket listener leak | `public/js/fish.js:714-729` | No `socket.off()` cleanup on client | +| 30 | Unvalidated ocean ID | `src/engine/engine.js:29` | `om.oceans[myOId]` accessed without existence check | + +--- + +## Low Severity + +| # | Issue | Location | Description | +|---|-------|----------|-------------| +| 11 | Incomplete error handling | `src/app.js:90-96` | Only catches `SyntaxError`, not other parsing errors | +| 17 | Arithmetic overflow | `src/engine/fisher.js:136,156` | No bounds check on money calculations | +| 22 | Input edge cases | `public/js/fish.js:112-123` | Large numbers in catch intent not handled | +| 26 | Greed out of bounds | `src/engine/fisher.js:46-75` | Greed can exceed [0,1] range with erratic bots | +| 28 | Double disconnect | `public/js/fish.js:577` | Handlers may fire after `socket.disconnect()` | +| 29 | Missing status validation | `src/routes/sessions.js:115-133` | Microworld capacity not validated before assignment | + +--- + +## Detailed Descriptions + +### Issue 1: Socket Event Handler Memory Leak + +**File:** `src/engine/engine.js:31-78` + +Multiple socket event handlers (`readRules`, `attemptToFish`, `recordIntendedCatch`, `goToSea`, `return`, `requestPause`, `requestResume`) are registered inside the `enteredOcean` callback but never removed when a socket disconnects or when the ocean is deleted. This accumulates listener references and leads to memory leaks with high participant turnover. + +**Fix:** Add `socket.removeAllListeners()` or individual `socket.off()` calls in the disconnect handler. + +--- + +### Issue 3: Race Condition in Ocean Assignment + +**File:** `src/engine/ocean-manager.js:44-67` + +The `assignFisherToOcean` function checks if an ocean `hasRoom()` and then calls `addFisher()`, but between these two operations, another request could join the same ocean, causing an overfull ocean. No atomic transaction or locking mechanism prevents concurrent modifications. + +**Fix:** Implement a mutex or use atomic check-and-update operations. + +--- + +### Issue 7: XSS Risk via .html() + +**Files:** `public/js/fish.js:173-176,217,229,241,245,255,489,495,505,578`, `public/js/dashboard.js:56,66,76`, `public/js/microworld.js:570`, `public/js/run-results.js:72,79` + +The `.html()` jQuery method sets raw HTML content. If any dynamic content from the server (like `ocean.preparationText`, `ocean.endTimeText`, microworld names/descriptions) contains user-controlled data with HTML/JavaScript, XSS injection is possible. + +**Fix:** Use `.text()` for plain text or sanitize HTML before rendering. + +--- + +### Issue 12: Hardcoded Session Secrets + +**File:** `src/app.js:98,101` + +```javascript +app.use(cookieParser('life is better under the sea')); +app.use(session({ + secret: 'life is better under the sea', +``` + +Session secrets should be loaded from environment variables, not hardcoded in source code. + +**Fix:** Use `process.env.SESSION_SECRET` with a fallback for development only. + +--- + +### Issue 20: Open Redirect Vulnerability + +**File:** `public/js/fish.js:588-596` + +```javascript +var url = ocean.redirectURL; +if (url && url.length > 0) { + // ... substitution logic ... + location.href = url; // Could redirect to attacker-controlled URL +} +``` + +If `ocean.redirectURL` is attacker-controlled (via microworld params set by an experimenter), this enables open redirect attacks that can be used for phishing. + +**Fix:** Validate that the redirect URL is on an allowlist of trusted domains, or only allow relative URLs. + +--- + +### Issue 24: Race Condition in Ocean Purging + +**File:** `src/engine/ocean-manager.js:73-122` + +The `purgeOceans` function has a two-stage purge process (schedule then delete) intended to handle out-of-order events. However: +1. Between the time `purgeScheduled = true` is set and the next cycle runs, new fisher events could arrive and access deleted oceans +2. No mutex or atomic check-and-delete operation +3. Events might still arrive after an ocean is marked removable but before it's actually deleted + +**Fix:** Implement proper locking or use a state machine pattern for ocean lifecycle. diff --git a/developer_scripts/backup_db.sh b/developer_scripts/backup_db.sh new file mode 100644 index 0000000..eee5645 --- /dev/null +++ b/developer_scripts/backup_db.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Dump the fish database, zip it, and upload to Dropbox via rclone. +# Skips the backup if no new runs have been saved since the last backup. +# Requires: mongodump, mongosh, rclone configured with a remote named "dropbox" +# Usage: ./backup_db.sh + +set -e + +LAST_RUN_FILE="$HOME/.fish_last_backup_run_id" +DROPBOX_DEST="dropbox:/fish-backups" + +LATEST_ID=$(mongosh fish --quiet --eval \ + "db.runs.findOne({}, {projection: {_id:1}, sort: {_id:-1}})?._id?.toString() ?? ''" \ + 2>/dev/null || echo "") + +LAST_ID=$(cat "$LAST_RUN_FILE" 2>/dev/null || echo "") + +if [ -n "$LATEST_ID" ] && [ "$LATEST_ID" = "$LAST_ID" ]; then + echo "No new runs since last backup. Skipping." + exit 0 +fi + +BACKUP_NAME="fish-backup-$(date +%Y%m%d-%H%M%S)" +BACKUP_DIR="$HOME/$BACKUP_NAME" +ARCHIVE="$HOME/$BACKUP_NAME.tar.gz" + +echo "Dumping database..." +mongodump --db fish --out "$BACKUP_DIR" + +echo "Compressing..." +tar -czf "$ARCHIVE" -C "$HOME" "$BACKUP_NAME" + +echo "Uploading to Dropbox..." +rclone copy "$ARCHIVE" "$DROPBOX_DEST" + +echo "Cleaning up local files..." +rm -rf "$BACKUP_DIR" "$ARCHIVE" + +[ -n "$LATEST_ID" ] && echo "$LATEST_ID" > "$LAST_RUN_FILE" + +echo "Done. Backup saved to $DROPBOX_DEST/$BACKUP_NAME.tar.gz" diff --git a/package.json b/package.json index 13acf5e..5839895 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "serve": "node dist/app.js", "servemon": "nodemon dist/app.js", "serve-pm2": "pm2 start dist/app.js --name Fish --log \"logs/fishlog_$( date '+%Y-%m-%d_%H-%M-%S' )\"", + "publish-log": "cp logs/$(ls -t logs/ | head -1) public/fishlog.log && echo 'Log available at /public/fishlog.log'", + "unpublish-log": "rm -f public/fishlog.log && echo 'Log removed'", "start": "set NODE_ENV=production && npm run build && npm run serve", "start-pm2": "set NODE_ENV=production && npm run build && npm run serve-pm2", "dev": "set NODE_ENV=development && npm run build-as-needed ; npm run servemon", diff --git a/public/css/base.css b/public/css/base.css index a1aa854..f87f599 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -47,9 +47,15 @@ h2 { .blue { color: blue; } +.navy { + color: navy; } + .red { color: red; } +.crimson { + color: crimson; } + .hidden { visibility: hidden; } diff --git a/public/css/fish.css b/public/css/fish.css index 607248a..3ad14e5 100644 --- a/public/css/fish.css +++ b/public/css/fish.css @@ -1,183 +1,260 @@ .col-sm-8, .col-sm-6, .col-sm-4 { padding-left: 30px; - padding-right: 30px; } + padding-right: 30px; +} .btn { line-height: 19px; - line-height: 1.9rem; } + line-height: 1.9rem; +} .btn-primary { background-color: #03A9F4; - border-color: #03A9F4; } - .btn-primary:hover { - background-color: #0288D1; - border-color: #0288D1; } + border-color: #03A9F4; +} +.btn-primary:hover { + background-color: #0288D1; + border-color: #0288D1; +} html { - font-size: 62.5%; } + font-size: 62.5%; +} #status-box { - text-align: center; } - #status-box #status-label { - font-size: 25px; - font-size: 2.5rem; - background-color: #0288D1; - border-color: #0288D1; - color: #FFFFFF; - margin: 1px; } - #status-box #status-sub-label { - font-size: 20px; - font-size: 2.0rem; - background-color: #B3E5FC; - border-color: #B3E5FC; - color: #000000; - margin: 1px; } + text-align: center; +} +#status-box #status-label { + font-size: 25px; + font-size: 2.5rem; + background-color: #0288D1; + border-color: #0288D1; + color: #FFFFFF; + margin: 1px; +} +#status-box #status-sub-label { + font-size: 20px; + font-size: 2rem; + background-color: #B3E5FC; + border-color: #B3E5FC; + color: #000000; + margin: 1px; +} #ocean-box { - position: relative; } - #ocean-box #warning-alert { - display: none; - position: absolute; - top: 100px; - left: 0; - right: 0; - margin-left: auto; - margin-right: auto; - text-align: center; - font-size: 15px; - font-size: 1.5rem; - background-color: #D32F2F; - border-color: #D32F2F; - color: #FFFFFF; } + position: relative; +} +#ocean-box #warning-alert { + display: none; + position: absolute; + top: 100px; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + text-align: center; + font-size: 15px; + font-size: 1.5rem; + background-color: #D32F2F; + border-color: #D32F2F; + color: #FFFFFF; +} #catch-intent-dialog { font-size: 18px; font-size: 1.8rem; font-weight: bold; - position: relative; } - #catch-intent-dialog div { - text-align: center; - display: inline-block; } - #catch-intent-dialog .prompt-label { - background-color: orange; + position: relative; +} + +#catch-intent-dialog div { + text-align: center; + display: inline-block; +} + +#catch-intent-dialog .prompt-label { + background-color: orange; } #costs-box { font-size: 18px; font-size: 1.8rem; - position: relative; } - #costs-box div { - text-align: center; - display: inline-block; } - #costs-box .costs-label { - background-color: white; - color: #505050; - border-color: white; } + position: relative; +} +#costs-box div { + text-align: center; + display: inline-block; +} +#costs-box .costs-label { + background-color: white; + color: #505050; + border-color: white; +} #control-box .col-xs-4 { - padding: 0; } - #control-box .col-xs-4 button { - width: 100%; - font-weight: bold; } - #control-box .col-xs-4 .btn-changeLocation { - background-color: #03A9F4; - border-color: #03A9F4; - color: #FFFFFF; } - #control-box .col-xs-4 .btn-changeLocation:hover, #control-box .col-xs-4 .btn-changeLocation:focus, #control-box .col-xs-4 .btn-changeLocation:active { - background-color: #0286c2; - border-color: #0286c2; } - #control-box .col-xs-4 .btn-changeLocation:disabled { - background-color: #B3E5FC; - border-color: #B3E5FC; } - #control-box .col-xs-4 .btn-fish { - background-color: #4CAF50; - border-color: #4CAF50; - color: #FFFFFF; } - #control-box .col-xs-4 .btn-fish:hover, #control-box .col-xs-4 .btn-fish:focus, #control-box .col-xs-4 .btn-fish:active { - background-color: #3d8b40; - border-color: #3d8b40; } - #control-box .col-xs-4 .btn-fish:disabled { - background-color: #6ec071; - border-color: #6ec071; } - #control-box .col-xs-4 .btn-pause { - background-color: #0288D1; - border-color: #0288D1; - color: #FFFFFF; } - #control-box .col-xs-4 .btn-pause:hover, #control-box .col-xs-4 .btn-pause:focus, #control-box .col-xs-4 .btn-pause:active { - background-color: #02679e; - border-color: #02679e; } - #control-box .col-xs-4 .btn-pause:disabled { - background-color: #09a7fd; - border-color: #09a7fd; } + padding: 0; +} +#control-box .col-xs-4 button { + width: 100%; + font-weight: bold; +} +#control-box .col-xs-4 .btn-changeLocation { + background-color: #03A9F4; + border-color: #03A9F4; + color: #FFFFFF; +} +#control-box .col-xs-4 .btn-changeLocation:hover, #control-box .col-xs-4 .btn-changeLocation:focus, #control-box .col-xs-4 .btn-changeLocation:active { + background-color: rgb(2.3805668016, 134.1052631579, 193.6194331984); + border-color: rgb(2.3805668016, 134.1052631579, 193.6194331984); +} +#control-box .col-xs-4 .btn-changeLocation:disabled { + background-color: #B3E5FC; + border-color: #B3E5FC; +} +#control-box .col-xs-4 .btn-fish { + background-color: #4CAF50; + border-color: #4CAF50; + color: #FFFFFF; +} +#control-box .col-xs-4 .btn-fish:hover, #control-box .col-xs-4 .btn-fish:focus, #control-box .col-xs-4 .btn-fish:active { + background-color: rgb(60.5577689243, 139.4422310757, 63.7450199203); + border-color: rgb(60.5577689243, 139.4422310757, 63.7450199203); +} +#control-box .col-xs-4 .btn-fish:disabled { + background-color: rgb(109.9800796813, 192.0199203187, 113.2948207171); + border-color: rgb(109.9800796813, 192.0199203187, 113.2948207171); +} +#control-box .col-xs-4 .btn-pause { + background-color: #0288D1; + border-color: #0288D1; + color: #FFFFFF; +} +#control-box .col-xs-4 .btn-pause:hover, #control-box .col-xs-4 .btn-pause:focus, #control-box .col-xs-4 .btn-pause:active { + background-color: rgb(1.5165876777, 103.1279620853, 158.4834123223); + border-color: rgb(1.5165876777, 103.1279620853, 158.4834123223); +} +#control-box .col-xs-4 .btn-pause:disabled { + background-color: rgb(9.3507109005, 166.8483412322, 252.6492890995); + border-color: rgb(9.3507109005, 166.8483412322, 252.6492890995); +} #fishers-box table { font-size: 16px; font-size: 1.6rem; - background-color: white; } - #fishers-box table thead th { - white-space: nowrap; - vertical-align: top; - text-align: center; } - #fishers-box table thead th img { - margin-right: 5px; - vertical-align: top; } - #fishers-box table thead th p { - overflow: hidden; - margin: 0; - display: inline-block; } - #fishers-box table tbody tr { - display: none; - font-weight: normal; } - #fishers-box table tbody tr td img { - float: left; - margin-right: 5px; } - #fishers-box table tbody tr td p { - overflow: hidden; } - #fishers-box table tbody tr td:nth-of-type(n+2) { - text-align: center; } - #fishers-box table tbody tr#f0 { - background-color: #4CAF50; - color: #FFFFFF; } - #fishers-box table tbody tr#f0 td { - background-color: #4CAF50; - color: #FFFFFF; } + background-color: white; +} +#fishers-box table thead th { + white-space: nowrap; + vertical-align: top; + text-align: center; +} +#fishers-box table thead th img { + margin-right: 5px; + vertical-align: top; +} +#fishers-box table thead th p { + overflow: hidden; + margin: 0; + display: inline-block; +} +#fishers-box table tbody tr { + display: none; + font-weight: normal; +} +#fishers-box table tbody tr td img { + float: left; + margin-right: 5px; +} +#fishers-box table tbody tr td p { + overflow: hidden; +} +#fishers-box table tbody tr td:nth-of-type(n+2) { + text-align: center; +} +#fishers-box table tbody tr#f0 { + background-color: #4CAF50; + color: #FFFFFF; +} +#fishers-box table tbody tr#f0 td { + background-color: #4CAF50; + color: #FFFFFF; +} + +#lobby-status-box { + margin-top: 20px; +} +#lobby-status-box table { + font-size: 16px; + font-size: 1.6rem; + background-color: white; +} +#lobby-status-box table thead tr { + background-color: #03A9F4; + color: white; +} +#lobby-status-box table thead tr th { + white-space: nowrap; + vertical-align: top; + text-align: center; +} +#lobby-status-box table tbody tr { + font-weight: normal; +} +#lobby-status-box table tbody tr td { + text-align: center; +} #canvas-container #ocean-canvas { display: block; margin: 0 auto 20px auto; - border-radius: 20px; } + border-radius: 20px; +} .modal { font-size: 16px; - font-size: 1.6rem; } - .modal button { - display: block; - margin: 0 auto; } - .modal .modal-footer { - margin-top: 0; } - -#tutorial { + font-size: 1.6rem; +} +.modal button { display: block; - margin: 15px auto 0 auto;} + margin: 0 auto; +} +.modal .modal-footer { + margin-top: 0; +} + +#maybe-abort-modal .modal-footer { + display: flex; + flex-direction: row; + justify-content: center; + gap: 8px; +} +#maybe-abort-modal .modal-footer button { + display: inline-block; + margin: 0; +} @media (min-width: 768px) { .col-sm-8 { - width: 75%; } - + width: 75%; + } .col-sm-6 { - width: 50%; } - + width: 50%; + } .col-sm-4 { width: 25%; - margin-top: 20px; } } + margin-top: 20px; + } +} @media (min-width: 992px) { .col-sm-8 { - width: 75%; } - + width: 75%; + } .col-sm-6 { - width: 50%; } - + width: 50%; + } .col-sm-4 { - width: 25%; } } + width: 25%; + } +} /*# sourceMappingURL=fish.css.map */ diff --git a/public/css/fish.css.map b/public/css/fish.css.map index a64934b..c3be238 100644 --- a/public/css/fish.css.map +++ b/public/css/fish.css.map @@ -1,7 +1 @@ -{ -"version": 3, -"mappings": "AAmBA,+BAAgC;EAC9B,YAAY,EAAE,IAAI;EAClB,aAAa,EAAE,IAAI;;AAGrB,IAAK;EACH,WAAW,EAAE,IAAI;EACjB,WAAW,EAAE,MAAM;;AAGrB,YAAa;EACX,gBAAgB,EAzBG,OAAO;EA0B1B,YAAY,EA1BO,OAAO;EA2B1B,kBAAQ;IACN,gBAAgB,EA7BC,OAAO;IA8BxB,YAAY,EA9BK,OAAO;;AAkC5B,IAAK;EAGH,SAAS,EAAE,KAAK;;AAGlB,WAAY;EACV,UAAU,EAAE,MAAM;EAClB,yBAAc;IACZ,SAAS,EAAE,IAAI;IACf,SAAS,EAAE,MAAM;IACjB,gBAAgB,EA7CC,OAAO;IA8CxB,YAAY,EA9CK,OAAO;IA+CxB,KAAK,EA5CY,OAAO;IA6CxB,MAAM,EAAE,GAAG;EAEb,6BAAkB;IAChB,SAAS,EAAE,IAAI;IACf,SAAS,EAAE,MAAM;IACjB,gBAAgB,EAnDC,OAAO;IAoDxB,YAAY,EApDK,OAAO;IAqDxB,KAAK,EA/CY,OAAO;IAgDxB,MAAM,EAAE,GAAG;;AAIf,UAAW;EACT,QAAQ,EAAE,QAAQ;EAClB,yBAAe;IACb,OAAO,EAAE,IAAI;IACb,QAAQ,EAAE,QAAQ;IAClB,GAAG,EAAE,KAAK;IACV,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,WAAW,EAAE,IAAI;IACjB,YAAY,EAAE,IAAI;IAClB,UAAU,EAAE,MAAM;IAClB,SAAS,EAAE,IAAI;IACf,SAAS,EAAE,MAAM;IACjB,gBAAgB,EA7DQ,OAAO;IA8D/B,YAAY,EA9DY,OAAO;IA+D/B,KAAK,EAxEY,OAAO;;AA4E5B,UAAW;EACT,SAAS,EAAE,IAAI;EACf,SAAS,EAAE,MAAM;EACjB,QAAQ,EAAE,QAAQ;EAClB,cAAI;IACF,UAAU,EAAE,MAAM;IAClB,OAAO,EAAE,YAAY;EAEvB,uBAAa;IACX,gBAAgB,EAAE,KAAK;IACvB,KAAK,EAAE,OAAO;IACd,YAAY,EAAE,KAAK;;AAKrB,sBAAU;EACR,OAAO,EAAE,CAAC;EACV,6BAAO;IACL,KAAK,EAAE,IAAI;IACX,WAAW,EAAE,IAAI;EAEnB,0CAAoB;IAClB,gBAAgB,EArGD,OAAO;IAsGtB,YAAY,EAtGG,OAAO;IAuGtB,KAAK,EArGU,OAAO;IAsGtB,qJAA2B;MACzB,gBAAgB,EAAE,OAA0B;MAC5C,YAAY,EAAE,OAA0B;IAE1C,mDAAW;MACT,gBAAgB,EA5GH,OAAO;MA6GpB,YAAY,EA7GC,OAAO;EAgHxB,gCAAU;IACR,gBAAgB,EAzGA,OAAY;IA0G5B,YAAY,EA1GI,OAAY;IA2G5B,KAAK,EAlHU,OAAO;IAmHtB,uHAA4B;MAC1B,gBAAgB,EAAE,OAAyB;MAC3C,YAAY,EAAE,OAAyB;IAEzC,yCAAW;MACT,gBAAgB,EAAE,OAA0B;MAC5C,YAAY,EAAE,OAA0B;EAG5C,iCAAW;IACT,gBAAgB,EAhID,OAAO;IAiItB,YAAY,EAjIG,OAAO;IAkItB,KAAK,EA/HU,OAAO;IAgItB,0HAA4B;MAC1B,gBAAgB,EAAE,OAA8B;MAChD,YAAY,EAAE,OAA8B;IAE9C,0CAAW;MACT,gBAAgB,EAAE,OAA+B;MACjD,YAAY,EAAE,OAA+B;;AAOnD,kBAAM;EACJ,SAAS,EAAE,IAAI;EACf,SAAS,EAAE,MAAM;EACjB,gBAAgB,EAAE,KAAK;EAErB,2BAAG;IACD,WAAW,EAAE,MAAM;IACnB,cAAc,EAAE,GAAG;IACnB,UAAU,EAAE,MAAM;IAClB,+BAAI;MACF,YAAY,EAAE,GAAG;MACjB,cAAc,EAAE,GAAG;IAErB,6BAAE;MACA,QAAQ,EAAE,MAAM;MAChB,MAAM,EAAE,CAAC;MACT,OAAO,EAAE,YAAY;EAKzB,2BAAG;IACD,OAAO,EAAE,IAAI;IACb,WAAW,EAAE,MAAM;IAEjB,kCAAI;MACF,KAAK,EAAE,IAAI;MACX,YAAY,EAAE,GAAG;IAEnB,gCAAE;MACA,QAAQ,EAAE,MAAM;IAElB,+CAAmB;MACjB,UAAU,EAAE,MAAM;EAIxB,8BAAM;IACJ,gBAAgB,EA5KF,OAAY;IA6K1B,KAAK,EApLQ,OAAO;IAqLpB,iCAAG;MACD,gBAAgB,EA/KJ,OAAY;MAgLxB,KAAK,EAvLM,OAAO;;AA+L1B,+BAAc;EACZ,OAAO,EAAE,KAAK;EACd,MAAM,EAAE,gBAAgB;EACxB,aAAa,EAAE,IAAI;;AAIvB,MAAO;EACL,SAAS,EAAE,IAAI;EACf,SAAS,EAAE,MAAM;EACjB,aAAO;IACL,OAAO,EAAC,KAAK;IACb,MAAM,EAAE,MAAM;EAEhB,oBAAc;IACZ,UAAU,EAAE,CAAC;;AAIjB,yBAAiC;EAC/B,SAAU;IACR,KAAK,EAAE,GAAG;;EAEZ,SAAU;IACR,KAAK,EAAE,GAAG;;EAEZ,SAAU;IACR,KAAK,EAAE,GAAG;IACV,UAAU,EAAE,IAAI;AAIpB,yBAAkC;EAChC,SAAU;IACR,KAAK,EAAE,GAAG;;EAEZ,SAAU;IACR,KAAK,EAAE,GAAG;;EAEZ,SAAU;IACR,KAAK,EAAE,GAAG", -"sources": ["../scss/fish.scss"], -"names": [], -"file": "fish.css" -} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["../scss/fish.scss"],"names":[],"mappings":"AAmBA;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE,kBAzBmB;EA0BnB,cA1BmB;;AA2BnB;EACE,kBA7BiB;EA8BjB,cA9BiB;;;AAkCrB;EAGE;;;AAGF;EACE;;AACA;EACE;EACA;EACA,kBA7CiB;EA8CjB,cA9CiB;EA+CjB,OA5CiB;EA6CjB;;AAEF;EACE;EACA;EACA,kBAnDiB;EAoDjB,cApDiB;EAqDjB,OA/CiB;EAgDjB;;;AAIJ;EACE;;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,kBA7DwB;EA8DxB,cA9DwB;EA+DxB,OAxEiB;;;AA4ErB;EACE;EACA;EACA;EACA;;;AACA;EACE;EACA;;;AACF;EACE;;;AAGJ;EACE;EACA;EACA;;AACA;EACE;EACA;;AAEF;EACE;EACA;EACA;;;AAKF;EACE;;AACA;EACE;EACA;;AAEF;EACE,kBAjHe;EAkHf,cAlHe;EAmHf,OAjHe;;AAkHf;EACE;EACA;;AAEF;EACE,kBAxHa;EAyHb,cAzHa;;AA4HjB;EACE,kBA3He;EA4Hf,cA5He;EA6Hf,OA9He;;AA+Hf;EACE;EACA;;AAEF;EACE;EACA;;AAGJ;EACE,kBA5Ie;EA6If,cA7Ie;EA8If,OA3Ie;;AA4If;EACE;EACA;;AAEF;EACE;EACA;;;AAON;EACE;EACA;EACA;;AAEE;EACE;EACA;EACA;;AACA;EACE;EACA;;AAEF;EACE;EACA;EACA;;AAKJ;EACE;EACA;;AAEE;EACE;EACA;;AAEF;EACE;;AAEF;EACE;;AAIN;EACE,kBA9La;EA+Lb,OAhMa;;AAiMb;EACE,kBAjMW;EAkMX,OAnMW;;;AA0MrB;EACE;;AACA;EACE;EACA;EACA;;AAEE;EACE,kBApNa;EAqNb;;AACA;EACE;EACA;EACA;;AAKJ;EACE;;AACA;EACE;;;AAQR;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;AACA;EACE;EACA;;AAEF;EACE;;;AAIJ;EACE;EACA;EACA;EACA;;AACA;EACE;EACA;;;AAIJ;EACE;IACE;;EAEF;IACE;;EAEF;IACE;IACA;;;AAIJ;EACE;IACE;;EAEF;IACE;;EAEF;IACE","file":"fish.css"} \ No newline at end of file diff --git a/public/js/fish.js b/public/js/fish.js index a101a2f..ed7c17d 100644 --- a/public/js/fish.js +++ b/public/js/fish.js @@ -6,6 +6,11 @@ var msgs; var socket = io.connect(); var mwId = $.url().param('mwid'); var pId = $.url().param('pid'); +var pParams = { + pDisplay: $.url().param('pdisplay'), + fClass: $.url().param('fclass'), + fHasAdvantage: parseHasAdvantage($.url().param('fhasadvantage')) +}; var ocean; var prePauseButtonsState = {}; @@ -21,6 +26,51 @@ mysteryFishImage.src = 'public/img/mystery-fish.png'; var st = { status: 'loading' }; +// Parse fhasadvantage URL parameter to boolean +function parseHasAdvantage(value) { + if (value === undefined) return false; // Not in URL = false + if (value === '' || value === null) return true; // In URL with no value = true + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return false; // Invalid value = false +} + +// Get the fish value for a fisher, accounting for advantage. +function getEffectiveFishValue(fisher) { + var base = ocean.fishValue; + if (ocean.fisherAdvantageEnabled && fisher.params && fisher.params.fHasAdvantage) { + base += (ocean.fishValuePayGap || 0); + } + return base; +} + +// Get the fish value used by the "other class" of fisher (with or without advantage). +function getOtherClassFishValue(currentFisher) { + if (!ocean.fisherAdvantageEnabled) return ocean.fishValue; + var currentHasAdvantage = currentFisher.params && currentFisher.params.fHasAdvantage; + if (currentHasAdvantage) { + return ocean.fishValue; + } else { + return ocean.fishValue + (ocean.fishValuePayGap || 0); + } +} + +// Compute profit gap: actual money minus hypothetical money with other class's fish value +function computeProfitGap(fisher) { + var otherFishValue = getOtherClassFishValue(fisher); + var hypotheticalMoney = fisher.totalFishCaught * otherFishValue; + return (fisher.money - hypotheticalMoney).toFixed(2); +} + +// Display a profit-gap value with sign prefix and color +function displayProfitGap($cell, value) { + var num = parseFloat(value); + var text = num > 0 ? '+' + value : value; + var color = num > 0 ? 'navy' : (num < 0 ? 'crimson' : ''); + $cell.text(text); + $cell.css('color', color); +} + if (lang && lang !== '' && lang.toLowerCase() in langs) { lang = lang.toLowerCase(); msgs = langs[lang]; @@ -137,31 +187,51 @@ function submitMyCatchIntent() { //////////// START Profit Columns Display Feature //////////////////////////////////////// -//controls visibility of both seasonal and overall profit columns in one function to hide -//for tutorial text, table column heading, and table body +// Helper functions to check per-column profit display settings. +function isProfitSeasonDisabled() { + return ocean.profitSeasonDisabled; +} +function isProfitTotalDisabled() { + return ocean.profitTotalDisabled; +} +function isProfitGapDisabled() { + return !ocean.fisherAdvantageEnabled || ocean.profitGapDisabled; +} +function areAllProfitColumnsDisabled() { + return isProfitSeasonDisabled() && isProfitTotalDisabled() && isProfitGapDisabled(); +} + +// Per-column hide functions. Each hides its column header, table heading, and cells, +// and removes bootstro class to prevent tutorial overlay issues. -// There is no need for the analogous 'show' function because that's the default, -// and the hide function is only called either once or not at all, depending on -// the setting in the experiment configuration. +function hideProfitSeasonColumn() { + $('#profit-season-header').hide().removeClass("bootstro"); + $('[id$="-profit-season"]').hide(); +} -function hideProfitColumns() { - $('#profit-season-header').hide(); - $('#profit-total-header').hide(); - $('#profit-season-th').hide(); - $('#profit-total-th').hide(); - for (var i in st.fishers) { - $('#f' + i + '-profit-season').hide(); - $('#f' + i + '-profit-total').hide(); - } +function hideProfitTotalColumn() { + $('#profit-total-header').hide().removeClass("bootstro"); + $('[id$="-profit-total"]').hide(); +} + +function hideProfitGapColumn() { + $('#profit-gap-header').hide().removeClass("bootstro"); + $('[id$="-profit-gap"]').hide(); +} + +function hideAllProfitExtras() { $("#costs-box").hide(); - // Prevent bootstro from choking on hidden profit tutorial data - $("#profit-season-header").removeClass("bootstro"); - $("#profit-total-header").removeClass("bootstro"); - $("#profit-season-th").removeClass("bootstro"); - $("#profit-total-th").removeClass("bootstro"); $("#costs-box").removeClass("bootstro"); } +// Convenience wrapper that hides all profit columns and the costs box. +function hideProfitColumns() { + hideProfitSeasonColumn(); + hideProfitTotalColumn(); + hideProfitGapColumn(); + hideAllProfitExtras(); +} + //////////////////////////////////////// //////////// END Profit Colum Display Feature (eadditional points below and related to showFisherBalance) //////////////////////////////////////// @@ -180,9 +250,18 @@ function loadLabels() { $('#fish-season-header').text(' ' + msgs.info_season); $('#fish-total-header').text(' ' + msgs.info_overall); + $('#lobby-status-header').text(msgs.lobby_header); + $('#lobby-waiting-header').text(msgs.lobby_headerWaiting); + + $('#abort-keep-reading').text(msgs.abort_keepReading); + $('#abort-proceed').text(msgs.abort_proceed); + $('#abort-keep-waiting').text(msgs.abort_keepWaiting); + $('#abort-confirm').text(msgs.abort_abort); + if (!ocean) return; $('#profit-season-header').text(ocean.currencySymbol + ' ' + msgs.info_season); $('#profit-total-header').text(ocean.currencySymbol + ' ' + msgs.info_overall); + $('#profit-gap-header').text(ocean.currencySymbol + ' ' + msgs.info_payGap); updateCosts(); updateStatus(); @@ -280,9 +359,13 @@ function clearWarnings() { function updateCosts() { if (!ocean) return; - if (ocean.fishValue !== 0) { + var displayFishValue = ocean.fishValue; + if (ocean.fisherAdvantageEnabled && pParams.fHasAdvantage) { + displayFishValue += (ocean.fishValuePayGap || 0); + } + if (displayFishValue !== 0) { $('#revenue-fish').text(msgs.costs_fishValue + ' ' + - ocean.currencySymbol + ocean.fishValue).show(); + ocean.currencySymbol + displayFishValue).show(); } else { $('#revenue-fish').hide(); } @@ -320,10 +403,25 @@ function updateFishers() { for (var i in st.fishers) { var fisher = st.fishers[i]; + var fisherClass = ocean.fisherClassesEnabled && fisher.params && fisher.params.fClass; + var classEmoji = fisherClass && ocean.fisherClassEmojis && ocean.fisherClassEmojis[fisherClass] ? ocean.fisherClassEmojis[fisherClass] : ''; + var advantageEmoji = ''; + if (ocean.fisherAdvantageEnabled && fisher.params) { + advantageEmoji = fisher.params.fHasAdvantage + ? (ocean.advantageEmoji || '') + : (ocean.disadvantageEmoji || ''); + } + var emojiList = [classEmoji, advantageEmoji].filter(Boolean); + var iconsHtml = emojiList.map(function (e) { + return '' + + $('').text(e).html() + ''; + }).join(''); + if (fisher.name === pId) { // This is you - name = msgs.info_you; - $('#f0-name').text(name); + var nameHtml = $('').text(msgs.info_you).html(); + name = iconsHtml ? iconsHtml + ' ' + msgs.info_you : msgs.info_you; + $('#f0-name').html(iconsHtml ? iconsHtml + ' ' + nameHtml : nameHtml); if (fisher.status === 'At port') { $('#f0-status').attr('src', '/public/img/anchor.png'); @@ -350,10 +448,17 @@ function updateFishers() { $('#f0-catch-intent').text(catchIntent); $('#f0-fish-season').text(fishSeason); $('#f0-fish-total').text(fishTotal); - if (!(ocean.profitDisplayDisabled)) { + if (!isProfitSeasonDisabled()) { $('#f0-profit-season').text(profitSeason); + } + if (!isProfitTotalDisabled()) { $('#f0-profit-total').text(profitTotal); } + if (!isProfitGapDisabled()) { + var profitGap = computeProfitGap(fisher); + displayProfitGap($('#f0-profit-gap'), profitGap); + $('#f0').attr('data-profit-gap', profitGap); + } $('#f0').attr('data-fish-total', fishTotal); $('#f0').attr('data-fish-season', fishSeason); @@ -367,11 +472,14 @@ function updateFishers() { $('#f' + j).show(); if (ocean.showFisherNames) { - name = fisher.name; + var pDisplay = (fisher.params && fisher.params.pDisplay) || fisher.name; + var nameHtml = $('').text(pDisplay).html(); + name = iconsHtml ? iconsHtml + ' ' + pDisplay : pDisplay; + $('#f' + j + '-name').html(iconsHtml ? iconsHtml + ' ' + nameHtml : nameHtml); } else { - name = j; + name = iconsHtml ? iconsHtml + ' ' + j : j; + $('#f' + j + '-name').html(iconsHtml ? iconsHtml + ' ' + j : j); } - $('#f' + j + '-name').text(name); var src = ''; if (!ocean.showFisherStatus) { @@ -404,15 +512,28 @@ function updateFishers() { $('#f' + j + '-fish-total').text('?'); } - if (ocean.profitDisplayDisabled) { - // ignore update profits - } else if (ocean.showFisherBalance) { - $('#f' + j + '-profit-season').text(profitSeason); - $('#f' + j + '-profit-total').text(profitTotal); + if (!isProfitSeasonDisabled()) { + if (ocean.showFisherBalance) { + $('#f' + j + '-profit-season').text(profitSeason); + } else { + $('#f' + j + '-profit-season').text('?'); + } } - else { - $('#f' + j + '-profit-season').text('?'); - $('#f' + j + '-profit-total').text('?'); + if (!isProfitTotalDisabled()) { + if (ocean.showFisherBalance) { + $('#f' + j + '-profit-total').text(profitTotal); + } else { + $('#f' + j + '-profit-total').text('?'); + } + } + if (!isProfitGapDisabled()) { + if (ocean.showFisherBalance) { + var profitGap = computeProfitGap(fisher); + displayProfitGap($('#f' + j + '-profit-gap'), profitGap); + $('#f' + j).attr('data-profit-gap', profitGap); + } else { + $('#f' + j + '-profit-gap').text('?'); + } } $('#f' + j).attr('data-fish-total', fishTotal); @@ -461,8 +582,17 @@ function hideTutorial() { if (!ocean.enableTutorial) $('#tutorial').hide(); } +function validateFisherAdvantage() { + if (!ocean.fisherAdvantageEnabled) { + pParams.fHasAdvantage = false; + return; + } +} + function setupOcean(o) { ocean = o; + validateFisherClass(); + validateFisherAdvantage(); displayRules(); loadLabels(); updateCosts(); @@ -470,13 +600,221 @@ function setupOcean(o) { hideTutorial(); hideCatchIntentColumn(); hideCatchIntentDialog(); - if (ocean.profitDisplayDisabled) { - hideProfitColumns(); + if (isProfitSeasonDisabled()) hideProfitSeasonColumn(); + if (isProfitTotalDisabled()) hideProfitTotalColumn(); + if (isProfitGapDisabled()) hideProfitGapColumn(); + if (areAllProfitColumnsDisabled()) hideAllProfitExtras(); +} + +// Validate and assign fClass URL parameter against microworld fisher classes +// Matching is case-insensitive; the stored (capitalized) class name is used. +function validateFisherClass() { + if (!ocean.fisherClassesEnabled) { + // Fisher classes not enabled, clear fClass + pParams.fClass = null; + return; + } + var validClasses = ocean.fisherClasses || []; + if (validClasses.length === 0) { + pParams.fClass = null; + return; + } + if (!pParams.fClass) { + // No fclass provided, assign first class + pParams.fClass = validClasses[0]; + } else { + // Case-insensitive lookup: find the canonical class name + var inputLower = pParams.fClass.toLowerCase(); + var matched = validClasses.filter(function(c) { return c.toLowerCase() === inputLower; }); + if (matched.length > 0) { + pParams.fClass = matched[0]; + } else { + console.warn('Invalid fclass "' + pParams.fClass + '". Valid classes are: ' + validClasses.join(', ') + '. Assigning to ' + validClasses[0]); + pParams.fClass = validClasses[0]; + } } } function readRules() { socket.emit('readRules'); + $('#lobby-status-box').show(); + startLobbyTimer(); +} + +//////////////////////////////////////// +//////////// Lobby status table +//////////////////////////////////////// + +var lobbySlots = []; +var lobbyTimer = null; + +function receiveLobbyStatus(data) { + lobbySlots = data.slots; + renderLobbyTable(); +} + +function formatMmSs(totalSecs) { + var m = Math.floor(totalSecs / 60); + var s = totalSecs % 60; + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; +} + +function renderLobbyTable() { + var now = Date.now(); + var rows = []; + + for (var i = 0; i < lobbySlots.length; i++) { + var slot = lobbySlots[i]; + var rowStatus, rowTime, rowSortKey; + + if (slot === null) { + rowStatus = msgs.lobby_fisherMissing; + rowTime = '---'; + rowSortKey = -1; + } else { + var entrySecs = Math.min(60*60-1, Math.max(0, Math.floor((now - slot.entryTime) / 1000))); + var isReady = slot.readyTime !== null; + rowStatus = slot.pId === pId ? msgs.info_you : (isReady ? msgs.lobby_fisherReady : msgs.lobby_fisherReading); + rowTime = formatMmSs(entrySecs); + rowSortKey = entrySecs; + } + + rows.push({ status: rowStatus, timeDisplay: rowTime, sortKey: rowSortKey }); + } + + rows.sort(function(a, b) { return b.sortKey - a.sortKey; }); + + var $tbody = $('#lobby-tbody'); + $tbody.empty(); + for (var j = 0; j < rows.length; j++) { + var row = rows[j]; + $tbody.append( + $('').append( + $('').text(row.status), + $('').text(row.timeDisplay) + ) + ); + } +} + +function startLobbyTimer() { + if (!lobbyTimer) { + lobbyTimer = setInterval(renderLobbyTable, 1000); + }} + +//////////////////////////////////////// +//////////// Abort / timeout feature +//////////////////////////////////////// + +var abortCountdownInterval = null; +var forceAbortCountdownInterval = null; +var simOverTimer = null; + +function startCountdown(seconds, intervalVar, displaySelector, doneFn) { + if (intervalVar) { + clearInterval(intervalVar); + } + var remaining = seconds; + $(displaySelector).text('(' + remaining + 's)'); + return setInterval(function() { + remaining -= 1; + $(displaySelector).text('(' + remaining + 's)'); + if (remaining <= 0) { + doneFn(); + } + }, 1000); +} + +function showAbortPrompt(data) { + var stage = data.stage; + if (stage === 'readingRules') { + $('#abort-prompt-message').text(ocean.abortReadingRulesText || msgs.abort_readingRulesMessage); + $('#abort-keep-reading').show(); + $('#abort-proceed').show(); + $('#abort-keep-waiting').hide(); + $('#rules-modal').modal('hide'); + } else { + $('#abort-prompt-message').text(ocean.abortLobbyWaitText || msgs.abort_lobbyWaitMessage); + $('#abort-keep-reading').hide(); + $('#abort-proceed').hide(); + $('#abort-keep-waiting').show(); + } + $('#maybe-abort-modal').modal({ keyboard: false, backdrop: 'static' }); + if (ocean.promptTimeout) { + abortCountdownInterval = startCountdown(ocean.promptTimeout, abortCountdownInterval, '#abort-countdown', function() { + hideAbortModal(); + showForceAbortModal(); + }); + } +} + +function clearAbortCountdown() { + if (abortCountdownInterval) { + clearInterval(abortCountdownInterval); + abortCountdownInterval = null; + } + $('#abort-countdown').text(''); +} + +function hideAbortModal() { + clearAbortCountdown(); + $('#maybe-abort-modal').modal('hide'); +} + +function doAbort() { + hideAbortModal(); + socket.emit('abortFish'); + var url = ocean.abortUrl; + if (url && url.length > 0) { + for (var key in queryParams) { + url = substituteQueryParameter(url, key); + } + location.href = url; + } +} + +function showForceAbortModal() { + $('#force-abort-message').text(ocean.forceAbortText || msgs.forceAbort_message); + $('#force-abort-ok').text(msgs.forceAbort_ok); + $('#force-abort-modal').modal({ keyboard: false, backdrop: 'static' }); + if (ocean.promptTimeout) { + forceAbortCountdownInterval = startCountdown(ocean.promptTimeout, forceAbortCountdownInterval, '#force-abort-countdown', function() { + clearForceAbortCountdown(); + doForceAbortOk(); + }); + } +} + +function clearForceAbortCountdown() { + if (forceAbortCountdownInterval) { + clearInterval(forceAbortCountdownInterval); + forceAbortCountdownInterval = null; + } + $('#force-abort-countdown').text(''); +} + +function doForceAbortOk() { + clearForceAbortCountdown(); + $('#force-abort-modal').modal('hide'); + doAbort(); +} + +function doAbortKeepReading() { + hideAbortModal(); + socket.emit('keepReading'); + displayRules(); +} + +function doAbortProceed() { + hideAbortModal(); + socket.emit('proceedToLobby'); + $('#lobby-status-box').show(); + startLobbyTimer(); +} + +function doAbortKeepWaiting() { + hideAbortModal(); + socket.emit('keepWaiting'); } function changeLocation() { @@ -533,6 +871,8 @@ function beginSeason(data) { } function warnInitialDelay() { + $('#lobby-status-box').hide(); + if (lobbyTimer) { clearInterval(lobbyTimer); lobbyTimer = null; } } function warnSeasonStart() { @@ -577,6 +917,13 @@ function endRun(trigger) { socket.disconnect(); $('#over-text').html(overText); $('#over-modal').modal({ keyboard: false, backdrop: 'static' }); + + if (ocean.redirectURL && ocean.redirectURL.length > 0 && ocean.promptTimeout) { + simOverTimer = setTimeout(function() { + simOverTimer = null; + maybeRedirect(); + }, ocean.promptTimeout * 1000); + } } // @@ -586,6 +933,10 @@ function endRun(trigger) { var queryParams = $.url().param(); function maybeRedirect() { + if (simOverTimer) { + clearTimeout(simOverTimer); + simOverTimer = null; + } // replace the keyword REDIRECTURL with the value of the redirectURL parameter var url = ocean.redirectURL; if (url && url.length > 0) { @@ -712,7 +1063,7 @@ function startTutorial() { } socket.on('connect', function () { - socket.emit('enterOcean', mwId, pId); + socket.emit('enterOcean', mwId, pId, pParams); }); socket.on('ocean', setupOcean); @@ -723,10 +1074,27 @@ socket.on('warn season start', warnSeasonStart); socket.on('warn season end', warnSeasonEnd); socket.on('end season', endSeason); socket.on('end run', endRun); +socket.on('lobbyStatus', receiveLobbyStatus); +socket.on('abortPrompt', showAbortPrompt); +socket.on('forceAbort', showForceAbortModal); socket.on('pause', pause); socket.on('resume', resume); socket.on('start asking intent', startAskingIntendedCatch); socket.on('stop asking intent', stopAskingIntendedCatch); +socket.on('joinError', function(data) { + alert(data.message); +}); + +socket.on('displaced', function() { + if (lobbyTimer) { clearInterval(lobbyTimer); lobbyTimer = null; } + clearAbortCountdown(); + clearForceAbortCountdown(); + hideAbortModal(); + disableButtons(); + $('#lobby-status-box').hide(); + $('#displaced-notice').show(); + socket.disconnect(); +}); function main() { hideCatchIntentColumn(); @@ -738,6 +1106,11 @@ function main() { $('#pause').on('click', requestPause); $('#resume').on('click', requestResume); $('#finished').on('click', maybeRedirect); + $('#abort-keep-reading').on('click', doAbortKeepReading); + $('#force-abort-ok').on('click', doForceAbortOk); + $('#abort-proceed').on('click', doAbortProceed); + $('#abort-keep-waiting').on('click', doAbortKeepWaiting); + $('#abort-confirm').on('click', doAbort); loadLabels(); resizeOceanCanvasToScreenWidth(); $(window).resize(resizeOceanCanvasToScreenWidth); diff --git a/public/js/fish.test.js b/public/js/fish.test.js index 473764c..7095660 100644 --- a/public/js/fish.test.js +++ b/public/js/fish.test.js @@ -77,71 +77,72 @@ describe('Fish (jsdom)', () => { } if (typeof selector === 'string') { - const element = document.querySelector(selector); + const elements = Array.from(document.querySelectorAll(selector)); + const element = elements[0] || null; return { text: function(val) { if (val !== undefined) { - if (element) element.textContent = val; + elements.forEach(el => el.textContent = val); return this; } return element ? element.textContent : ''; }, val: function(val) { if (val !== undefined) { - if (element) element.value = val; + elements.forEach(el => el.value = val); return this; } return element ? element.value : ''; }, html: function(val) { if (val !== undefined) { - if (element) element.innerHTML = val; + elements.forEach(el => el.innerHTML = val); return this; } return element ? element.innerHTML : ''; }, attr: function(name, val) { if (val !== undefined) { - if (element) element.setAttribute(name, val); + elements.forEach(el => el.setAttribute(name, val)); return this; } return element ? element.getAttribute(name) : null; }, prop: function(name, val) { if (val !== undefined) { - if (element) element[name] = val; + elements.forEach(el => el[name] = val); return this; } return element ? element[name] : undefined; }, addClass: function(className) { - if (element) element.classList.add(className); + elements.forEach(el => el.classList.add(className)); return this; }, removeClass: function(className) { - if (element) element.classList.remove(className); + elements.forEach(el => el.classList.remove(className)); return this; }, hasClass: function(className) { return element ? element.classList.contains(className) : false; }, show: function() { - if (element) element.style.display = ''; + elements.forEach(el => el.style.display = ''); return this; }, hide: function() { - if (element) element.style.display = 'none'; + elements.forEach(el => el.style.display = 'none'); return this; }, on: function(event, handler) { - if (element) element.addEventListener(event, handler); + elements.forEach(el => el.addEventListener(event, handler)); return this; }, trigger: function(event) { - if (element) { + elements.forEach(el => { const evt = new window.Event(event); - element.dispatchEvent(evt); - } + el.dispatchEvent(evt); + }); return this; }, ready: function(handler) { @@ -151,15 +152,15 @@ describe('Fish (jsdom)', () => { }, width: function(val) { if (val !== undefined) { - if (element) element.style.width = val + 'px'; + elements.forEach(el => el.style.width = val + 'px'); return this; } return element ? (element.offsetWidth || 800) : 0; }, each: function(callback) { - if (element) { - callback.call(element, 0, element); - } + elements.forEach((el, i) => { + callback.call(el, i, el); + }); return this; }, find: function(selector) { @@ -167,7 +168,7 @@ describe('Fish (jsdom)', () => { return jQuery(found ? '#' + (found.id || 'not-found') : '#not-found'); }, fadeOut: function(duration, callback) { - if (element) element.style.display = 'none'; + elements.forEach(el => el.style.display = 'none'); if (typeof duration === 'function') { duration(); } else if (callback) { @@ -176,7 +177,7 @@ describe('Fish (jsdom)', () => { return this; }, fadeIn: function(duration, callback) { - if (element) element.style.display = ''; + elements.forEach(el => el.style.display = ''); if (typeof duration === 'function') { duration(); } else if (callback) { @@ -187,20 +188,29 @@ describe('Fish (jsdom)', () => { data: function(name, val) { if (!element) return val === undefined ? undefined : this; if (val !== undefined) { - element.setAttribute('data-' + name, val); + elements.forEach(el => el.setAttribute('data-' + name, val)); return this; } return element.getAttribute('data-' + name); }, removeAttr: function(name) { - if (element) element.removeAttribute(name); + elements.forEach(el => el.removeAttribute(name)); return this; }, + css: function(prop, val) { + if (val !== undefined) { + elements.forEach(el => el.style[prop] = val); + return this; + } + return element ? element.style[prop] : ''; + }, modal: function(options) { // Mock Bootstrap modal - if (element && typeof element.modal === 'function') { - element.modal(options); - } + elements.forEach(el => { + if (typeof el.modal === 'function') { + el.modal(options); + } + }); return this; } }; @@ -227,7 +237,9 @@ describe('Fish (jsdom)', () => { const params = { mwid: '123', pid: '456', - lang: 'en' + lang: 'en', + pdisplay: 'TestPlayer', + fclass: 'GroupA' }; return params[name]; } @@ -597,6 +609,14 @@ describe('Fish (jsdom)', () => { window.pId.should.equal('456'); }); + it('should set pParams object from query params', () => { + // pParams may be in window scope or accessed differently due to jsdom + // Check that the mock URL params are correctly configured + const urlParams = window.$.url().param; + urlParams('pdisplay').should.equal('TestPlayer'); + urlParams('fclass').should.equal('GroupA'); + }); + it('should initialize socket connection', () => { should.exist(window.socket); }); @@ -605,13 +625,18 @@ describe('Fish (jsdom)', () => { describe('UI Display Functions', () => { beforeEach(() => { // Add necessary DOM elements + const profitElements = [ + 'profit-season-header', 'profit-total-header', 'profit-gap-header', + 'profit-season-th', 'profit-total-th', + 'f0-profit-season', 'f1-profit-season', + 'f0-profit-total', 'f1-profit-total', + 'f0-profit-gap', 'f1-profit-gap', + 'costs-box' + ]; if (!document.querySelector('#profit-season-header')) { const elements = [ - 'profit-season-header', 'profit-total-header', - 'profit-season-th', 'profit-total-th', - 'f0-profit-season', 'f1-profit-season', - 'f0-profit-total', 'f1-profit-total', - 'costs-box', 'read-rules', 'changeLocation', + ...profitElements, + 'read-rules', 'changeLocation', 'attempt-fish', 'pause', 'resume', 'fisher-header', 'fish-season-header', 'fish-total-header', 'revenue-fish', 'cost-departure', 'cost-cast', 'cost-second', @@ -627,6 +652,12 @@ describe('Fish (jsdom)', () => { }); } + // Reset display styles for profit-related elements between tests + profitElements.forEach(id => { + const el = document.querySelector('#' + id); + if (el) el.style.display = ''; + }); + window.st = { fishers: [ { name: 'Fisher 1' }, @@ -648,7 +679,9 @@ describe('Fish (jsdom)', () => { preparationText: 'Welcome to the fish game!\nGood luck!', enablePause: true, enableTutorial: true, - profitDisplayDisabled: false + profitSeasonDisabled: false, + profitTotalDisabled: false, + profitGapDisabled: false }; }); @@ -658,8 +691,6 @@ describe('Fish (jsdom)', () => { document.querySelector('#profit-season-header').style.display.should.equal('none'); document.querySelector('#profit-total-header').style.display.should.equal('none'); - document.querySelector('#profit-season-th').style.display.should.equal('none'); - document.querySelector('#profit-total-th').style.display.should.equal('none'); }); it('should hide profit columns for all fishers', () => { @@ -687,6 +718,57 @@ describe('Fish (jsdom)', () => { }); }); + describe('hideProfitSeasonColumn()', () => { + it('should hide only season profit elements', () => { + window.hideProfitSeasonColumn(); + + document.querySelector('#profit-season-header').style.display.should.equal('none'); + document.querySelector('#f0-profit-season').style.display.should.equal('none'); + document.querySelector('#f1-profit-season').style.display.should.equal('none'); + }); + + it('should not hide total or gap columns', () => { + window.hideProfitSeasonColumn(); + + document.querySelector('#profit-total-header').style.display.should.not.equal('none'); + document.querySelector('#profit-gap-header').style.display.should.not.equal('none'); + }); + }); + + describe('hideProfitTotalColumn()', () => { + it('should hide only total profit elements', () => { + window.hideProfitTotalColumn(); + + document.querySelector('#profit-total-header').style.display.should.equal('none'); + document.querySelector('#f0-profit-total').style.display.should.equal('none'); + document.querySelector('#f1-profit-total').style.display.should.equal('none'); + }); + + it('should not hide season or gap columns', () => { + window.hideProfitTotalColumn(); + + document.querySelector('#profit-season-header').style.display.should.not.equal('none'); + document.querySelector('#profit-gap-header').style.display.should.not.equal('none'); + }); + }); + + describe('hideProfitGapColumn()', () => { + it('should hide only gap profit elements', () => { + window.hideProfitGapColumn(); + + document.querySelector('#profit-gap-header').style.display.should.equal('none'); + document.querySelector('#f0-profit-gap').style.display.should.equal('none'); + document.querySelector('#f1-profit-gap').style.display.should.equal('none'); + }); + + it('should not hide season or total columns', () => { + window.hideProfitGapColumn(); + + document.querySelector('#profit-season-header').style.display.should.not.equal('none'); + document.querySelector('#profit-total-header').style.display.should.not.equal('none'); + }); + }); + describe('disableButtons()', () => { it('should disable all action buttons', () => { window.disableButtons(); @@ -1023,8 +1105,21 @@ describe('Fish (jsdom)', () => { reportedMysteryFish: 0 }; + // Reset display styles for profit-related elements between tests + ['profit-season-header', 'profit-total-header', 'profit-gap-header', + 'profit-season-th', 'profit-total-th', + 'f0-profit-season', 'f1-profit-season', + 'f0-profit-total', 'f1-profit-total', + 'f0-profit-gap', 'f1-profit-gap', + 'costs-box'].forEach(id => { + const el = document.querySelector('#' + id); + if (el) el.style.display = ''; + }); + window.ocean = { - profitDisplayDisabled: false, + profitSeasonDisabled: false, + profitTotalDisabled: false, + profitGapDisabled: false, enablePause: true, enableTutorial: true, currencySymbol: '$', @@ -1036,7 +1131,9 @@ describe('Fish (jsdom)', () => { describe('setupOcean()', () => { it('should call all ocean setup functions', () => { const testOcean = { - profitDisplayDisabled: false, + profitSeasonDisabled: false, + profitTotalDisabled: false, + profitGapDisabled: false, enablePause: true, enableTutorial: true, preparationText: 'Welcome!', @@ -1058,9 +1155,12 @@ describe('Fish (jsdom)', () => { catchIntentTh.style.display.should.equal('none'); }); - it('should hide profit columns when profit display is disabled', () => { + it('should hide only season column when profitSeasonDisabled is true', () => { const testOcean = { - profitDisplayDisabled: true, + profitSeasonDisabled: true, + profitTotalDisabled: false, + profitGapDisabled: false, + fisherAdvantageEnabled: true, enablePause: true, enableTutorial: true, preparationText: 'Welcome!', @@ -1070,17 +1170,30 @@ describe('Fish (jsdom)', () => { costSecond: 0 }; - // Create profit elements - ['profit-season-header', 'profit-total-header'].forEach(id => { - const elem = document.createElement('div'); - elem.id = id; - document.body.appendChild(elem); - }); + window.setupOcean(testOcean); + + document.querySelector('#profit-season-header').style.display.should.equal('none'); + document.querySelector('#profit-total-header').style.display.should.not.equal('none'); + document.querySelector('#profit-gap-header').style.display.should.not.equal('none'); + }); + + it('should hide costs box only when all three profit columns are disabled', () => { + const testOcean = { + profitSeasonDisabled: true, + profitTotalDisabled: true, + profitGapDisabled: true, + enablePause: true, + enableTutorial: true, + preparationText: 'Welcome!', + fishValue: 1.0, + costDeparture: 0, + costCast: 0, + costSecond: 0 + }; window.setupOcean(testOcean); - const profitHeader = document.querySelector('#profit-season-header'); - profitHeader.style.display.should.equal('none'); + document.querySelector('#costs-box').style.display.should.equal('none'); }); }); @@ -1312,6 +1425,215 @@ describe('Fish (jsdom)', () => { }); }); + describe('updateFishers() with pDisplay', () => { + beforeEach(() => { + // Add fisher name display elements + for (let i = 0; i <= 3; i++) { + const nameElem = document.createElement('div'); + nameElem.id = 'f' + i + '-name'; + document.body.appendChild(nameElem); + + const statusElem = document.createElement('img'); + statusElem.id = 'f' + i + '-status'; + document.body.appendChild(statusElem); + + const fishSeasonElem = document.createElement('div'); + fishSeasonElem.id = 'f' + i + '-fish-season'; + document.body.appendChild(fishSeasonElem); + + const fishTotalElem = document.createElement('div'); + fishTotalElem.id = 'f' + i + '-fish-total'; + document.body.appendChild(fishTotalElem); + + const containerElem = document.createElement('div'); + containerElem.id = 'f' + i; + document.body.appendChild(containerElem); + } + + window.pId = '456'; + window.myCatchIntentDisplaySeason = 0; + window.queryParams = {}; + window.msgs = window.langs.en; + window.msgs.info_you = 'You'; + }); + + it('should display pDisplay for other fishers when showFisherNames is true', () => { + window.ocean = { + showFishers: true, + showFisherNames: true, + showFisherStatus: true, + showNumCaught: true, + showFisherBalance: true + }; + + window.st = { + season: 0, + fishers: [ + { + name: '456', + params: { pDisplay: 'CurrentPlayer', pClass: 'GroupA' }, + status: 'At port', + totalFishCaught: 10, + money: 50.00, + seasonData: [{ catchIntent: 5, nextCatchIntent: 5, fishCaught: 10, endMoney: 50.00 }] + }, + { + name: 'other-fisher-1', + params: { pDisplay: 'Alice', pClass: 'GroupB' }, + status: 'At sea', + totalFishCaught: 8, + money: 40.00, + seasonData: [{ catchIntent: 4, nextCatchIntent: 4, fishCaught: 8, endMoney: 40.00 }] + }, + { + name: 'other-fisher-2', + params: { pDisplay: 'Bob', pClass: 'GroupA' }, + status: 'At port', + totalFishCaught: 12, + money: 60.00, + seasonData: [{ catchIntent: 6, nextCatchIntent: 6, fishCaught: 12, endMoney: 60.00 }] + } + ] + }; + + window.updateFishers(); + + // Current player should show "You" + document.querySelector('#f0-name').textContent.should.equal('You'); + + // Other fishers should show their pDisplay values + document.querySelector('#f1-name').textContent.should.equal('Alice'); + document.querySelector('#f2-name').textContent.should.equal('Bob'); + }); + + it('should display class emoji next to name when fisher classes enabled', () => { + window.ocean = { + showFishers: true, + showFisherNames: true, + showFisherStatus: true, + showNumCaught: true, + showFisherBalance: true, + fisherClassesEnabled: true, + fisherClasses: ['Class A', 'Class B'], + fisherClassEmojis: { 'Class A': '⭐', 'Class B': '😀' } + }; + + window.st = { + season: 0, + fishers: [ + { + name: '456', + params: { pDisplay: 'CurrentPlayer' }, + status: 'At port', + totalFishCaught: 10, + money: 50.00, + seasonData: [{ catchIntent: 5, nextCatchIntent: 5, fishCaught: 10, endMoney: 50.00 }] + }, + { + name: 'other-fisher-1', + params: { pDisplay: 'Alice', fClass: 'Class A' }, + status: 'At sea', + totalFishCaught: 8, + money: 40.00, + seasonData: [{ catchIntent: 4, nextCatchIntent: 4, fishCaught: 8, endMoney: 40.00 }] + }, + { + name: 'other-fisher-2', + params: { pDisplay: 'Bob', fClass: 'Class B' }, + status: 'At port', + totalFishCaught: 12, + money: 60.00, + seasonData: [{ catchIntent: 6, nextCatchIntent: 6, fishCaught: 12, endMoney: 60.00 }] + } + ] + }; + + window.updateFishers(); + + // Fisher with Class A should show "⭐ Alice" + document.querySelector('#f1-name').textContent.should.equal('⭐ Alice'); + // Fisher with Class B should show "😀 Bob" + document.querySelector('#f2-name').textContent.should.equal('😀 Bob'); + }); + + it('should display index number when showFisherNames is false', () => { + window.ocean = { + showFishers: true, + showFisherNames: false, + showFisherStatus: true, + showNumCaught: true, + showFisherBalance: true + }; + + window.st = { + season: 0, + fishers: [ + { + name: '456', + pDisplay: 'CurrentPlayer', + status: 'At port', + totalFishCaught: 10, + money: 50.00, + seasonData: [{ catchIntent: 5, nextCatchIntent: 5, fishCaught: 10, endMoney: 50.00 }] + }, + { + name: 'other-fisher-1', + pDisplay: 'Alice', + status: 'At sea', + totalFishCaught: 8, + money: 40.00, + seasonData: [{ catchIntent: 4, nextCatchIntent: 4, fishCaught: 8, endMoney: 40.00 }] + } + ] + }; + + window.updateFishers(); + + // Other fisher should show index number (1) instead of pDisplay + document.querySelector('#f1-name').textContent.should.equal('1'); + }); + + it('should fallback to fisher.name when pDisplay is undefined', () => { + window.ocean = { + showFishers: true, + showFisherNames: true, + showFisherStatus: true, + showNumCaught: true, + showFisherBalance: true + }; + + window.st = { + season: 0, + fishers: [ + { + name: '456', + pDisplay: 'CurrentPlayer', + status: 'At port', + totalFishCaught: 10, + money: 50.00, + seasonData: [{ catchIntent: 5, nextCatchIntent: 5, fishCaught: 10, endMoney: 50.00 }] + }, + { + name: 'fisher-without-pdisplay', + // pDisplay is undefined - should fallback to name + status: 'At sea', + totalFishCaught: 8, + money: 40.00, + seasonData: [{ catchIntent: 4, nextCatchIntent: 4, fishCaught: 8, endMoney: 40.00 }] + } + ] + }; + + window.updateFishers(); + + // Should display undefined (since pDisplay is not set and we're displaying fisher.pDisplay) + // This tests current behavior - if fallback is needed, the Fisher constructor handles it + const displayedName = document.querySelector('#f1-name').textContent; + // The value will be 'undefined' as string since pDisplay property doesn't exist + should.exist(displayedName); + }); + }); + describe('maybeRedirect()', () => { it('should not redirect if redirectURL is empty', () => { window.ocean.redirectURL = ''; @@ -1328,4 +1650,136 @@ describe('Fish (jsdom)', () => { }); }); }); + + describe('Lobby status table', () => { + before(() => { + // Add DOM elements needed by lobby functions + ['lobby-status-box', 'lobby-tbody'].forEach(id => { + if (!document.getElementById(id)) { + const tag = id === 'lobby-tbody' ? 'tbody' : 'div'; + const el = document.createElement(tag); + el.id = id; + document.body.appendChild(el); + } + }); + + // Extend the jQuery mock to support empty(), append(), and $('') creation + const orig$ = window.$; + window.$ = function(selector) { + // Handle HTML element creation: $(''), $(''), etc. + if (typeof selector === 'string' && /^<\w+>$/.test(selector)) { + const el = document.createElement(selector.slice(1, -1)); + const obj = { + _domEl: el, + text: function(val) { + if (val !== undefined) { el.textContent = String(val); return obj; } + return el.textContent; + }, + append: function() { + Array.from(arguments).forEach(function(item) { + if (item && item._domEl) el.appendChild(item._domEl); + }); + return obj; + } + }; + return obj; + } + const result = orig$(selector); + if (!result.empty) { + const els = Array.from(document.querySelectorAll(selector)); + result.empty = function() { + els.forEach(function(el) { el.innerHTML = ''; }); + return result; + }; + result.append = function() { + Array.from(arguments).forEach(function(item) { + if (item && item._domEl) { + els.forEach(function(el) { el.appendChild(item._domEl); }); + } + }); + return result; + }; + } + return result; + }; + window.$.url = orig$.url; + }); + + describe('receiveLobbyStatus()', () => { + it('should store slots in lobbySlots', () => { + const slots = [{ entryTime: Date.now(), readyTime: null }, null]; + window.receiveLobbyStatus({ slots: slots }); + window.lobbySlots.should.deepEqual(slots); + }); + }); + + describe('warnInitialDelay()', () => { + it('should hide #lobby-status-box', () => { + document.getElementById('lobby-status-box').style.display = ''; + window.warnInitialDelay(); + document.getElementById('lobby-status-box').style.display.should.equal('none'); + }); + }); + + describe('renderLobbyTable()', () => { + it('should create one row per slot', () => { + const now = Date.now(); + window.lobbySlots = [ + { entryTime: now - 120000, readyTime: now - 60000 }, + { entryTime: now - 60000, readyTime: null }, + null + ]; + window.renderLobbyTable(); + document.getElementById('lobby-tbody').querySelectorAll('tr').length.should.equal(3); + }); + + it('should show "Fisher missing" for null slots', () => { + window.lobbySlots = [null]; + window.renderLobbyTable(); + document.getElementById('lobby-tbody').querySelectorAll('td')[0].textContent.should.equal('Fisher missing'); + }); + + it('should show "---" time for null slots', () => { + window.lobbySlots = [null]; + window.renderLobbyTable(); + document.getElementById('lobby-tbody').querySelectorAll('td')[1].textContent.should.equal('---'); + }); + + it('should show "Fisher reading rules" when readyTime is null', () => { + window.lobbySlots = [{ entryTime: Date.now(), readyTime: null }]; + window.renderLobbyTable(); + document.getElementById('lobby-tbody').querySelectorAll('td')[0].textContent.should.equal('Fisher reading rules'); + }); + + it('should show "Fisher ready and waiting" when readyTime is set', () => { + window.lobbySlots = [{ entryTime: Date.now() - 120000, readyTime: Date.now() - 60000 }]; + window.renderLobbyTable(); + document.getElementById('lobby-tbody').querySelectorAll('td')[0].textContent.should.equal('Fisher ready and waiting'); + }); + + it('should sort longest wait first', () => { + const now = Date.now(); + window.lobbySlots = [ + { entryTime: now - 60000, readyTime: null }, // 1 min reading + { entryTime: now - 180000, readyTime: now - 120000 }, // 2 min ready + ]; + window.renderLobbyTable(); + const rows = document.getElementById('lobby-tbody').querySelectorAll('tr'); + rows[0].querySelectorAll('td')[0].textContent.should.equal('Fisher ready and waiting'); + rows[1].querySelectorAll('td')[0].textContent.should.equal('Fisher reading rules'); + }); + + it('should place missing slots at the bottom', () => { + const now = Date.now(); + window.lobbySlots = [ + null, + { entryTime: now - 60000, readyTime: null }, + ]; + window.renderLobbyTable(); + const rows = document.getElementById('lobby-tbody').querySelectorAll('tr'); + rows[0].querySelectorAll('td')[0].textContent.should.equal('Fisher reading rules'); + rows[1].querySelectorAll('td')[0].textContent.should.equal('Fisher missing'); + }); + }); + }); }); diff --git a/public/js/localization.js b/public/js/localization.js index f282d1c..9559859 100644 --- a/public/js/localization.js +++ b/public/js/localization.js @@ -47,14 +47,14 @@ pt['costs_costLeave'] = 'Deixar o porto custa'; ko['costs_costLeave'] = '출항 비용'; // Status -en['status_wait'] = 'Please wait'; -cn['status_wait'] = '请稍候'; -ct['status_wait'] = '請稍候'; -de['status_wait'] = 'Bitte warten'; -es['status_wait'] = 'Espera por favor'; -fr['status_wait'] = 'Veuillez patienter'; -pt['status_wait'] = 'Por favor aguarde'; -ko['status_wait'] = '잠시만 기다려주십시오'; +en['status_wait'] = 'Please wait in the lobby'; +cn['status_wait'] = '请在大厅等候'; +ct['status_wait'] = '請在大廳等候'; +de['status_wait'] = 'Bitte warten Sie in der Lobby'; +es['status_wait'] = 'Por favor espere en el vestíbulo'; +fr['status_wait'] = 'Veuillez patienter dans le salon'; +pt['status_wait'] = 'Por favor aguarde no lobby'; +ko['status_wait'] = '로비에서 기다려주십시오'; en['status_subWait'] = 'Loading the application'; en['status_subWait'] = 'while other fishers are joining'; // RMK @@ -257,6 +257,134 @@ fr['info_overall'] = 'En tout'; pt['info_overall'] = 'Total'; ko['info_overall'] = '총'; +en['info_payGap'] = 'Pay Gap'; +cn['info_payGap'] = 'Pay Gap'; +ct['info_payGap'] = 'Pay Gap'; +de['info_payGap'] = 'Pay Gap'; +es['info_payGap'] = 'Pay Gap'; +fr['info_payGap'] = 'Pay Gap'; +pt['info_payGap'] = 'Pay Gap'; +ko['info_payGap'] = 'Pay Gap'; + +// Abort / timeout +en['abort_readingRulesMessage'] = "You've been reading the rules for a while. Would you like to continue reading, proceed to the lobby, or leave the simulation?"; +cn['abort_readingRulesMessage'] = '您已阅读规则一段时间。您想继续阅读、进入大厅还是离开模拟?'; +ct['abort_readingRulesMessage'] = '您已閱讀規則一段時間。您想繼續閱讀、進入大廳還是離開模擬?'; +de['abort_readingRulesMessage'] = 'Sie lesen die Regeln schon eine Weile. Möchten Sie weiterlesen, zur Lobby gehen oder die Simulation verlassen?'; +es['abort_readingRulesMessage'] = 'Has estado leyendo las reglas por un tiempo. ¿Deseas seguir leyendo, ir al lobby o abandonar la simulación?'; +fr['abort_readingRulesMessage'] = "Vous lisez les règles depuis un moment. Voulez-vous continuer à lire, passer au lobby ou quitter la simulation ?"; +pt['abort_readingRulesMessage'] = 'Você está lendo as regras há algum tempo. Gostaria de continuar lendo, ir ao lobby ou sair da simulação?'; +ko['abort_readingRulesMessage'] = '규칙을 꽤 오래 읽고 있습니다. 계속 읽으시겠습니까, 로비로 이동하시겠습니까, 아니면 시뮬레이션을 종료하시겠습니까?'; + +en['abort_lobbyWaitMessage'] = "You've been waiting for the simulation to start for a while. Would you like to keep waiting or leave the simulation?"; +cn['abort_lobbyWaitMessage'] = '您等待模拟开始已有一段时间。您想继续等待还是离开模拟?'; +ct['abort_lobbyWaitMessage'] = '您等待模擬開始已有一段時間。您想繼續等待還是離開模擬?'; +de['abort_lobbyWaitMessage'] = 'Sie warten schon eine Weile auf den Start der Simulation. Möchten Sie weiter warten oder die Simulation verlassen?'; +es['abort_lobbyWaitMessage'] = 'Has estado esperando que comience la simulación por un tiempo. ¿Deseas seguir esperando o abandonar la simulación?'; +fr['abort_lobbyWaitMessage'] = "Vous attendez depuis un moment que la simulation commence. Voulez-vous continuer à attendre ou quitter la simulation ?"; +pt['abort_lobbyWaitMessage'] = 'Você está esperando a simulação começar há algum tempo. Gostaria de continuar esperando ou sair da simulação?'; +ko['abort_lobbyWaitMessage'] = '시뮬레이션 시작을 꽤 오래 기다리고 있습니다. 계속 기다리시겠습니까 아니면 시뮬레이션을 종료하시겠습니까?'; + +en['abort_keepReading'] = 'Keep Reading'; +cn['abort_keepReading'] = '继续阅读'; +ct['abort_keepReading'] = '繼續閱讀'; +de['abort_keepReading'] = 'Weiterlesen'; +es['abort_keepReading'] = 'Seguir leyendo'; +fr['abort_keepReading'] = 'Continuer à lire'; +pt['abort_keepReading'] = 'Continuar lendo'; +ko['abort_keepReading'] = '계속 읽기'; + +en['abort_proceed'] = 'Proceed to Lobby'; +cn['abort_proceed'] = '进入大厅'; +ct['abort_proceed'] = '進入大廳'; +de['abort_proceed'] = 'Zur Lobby'; +es['abort_proceed'] = 'Ir al lobby'; +fr['abort_proceed'] = 'Aller au lobby'; +pt['abort_proceed'] = 'Ir ao lobby'; +ko['abort_proceed'] = '로비로 이동'; + +en['abort_keepWaiting'] = 'Keep Waiting'; +cn['abort_keepWaiting'] = '继续等待'; +ct['abort_keepWaiting'] = '繼續等待'; +de['abort_keepWaiting'] = 'Weiter warten'; +es['abort_keepWaiting'] = 'Seguir esperando'; +fr['abort_keepWaiting'] = 'Continuer à attendre'; +pt['abort_keepWaiting'] = 'Continuar aguardando'; +ko['abort_keepWaiting'] = '계속 대기'; + +en['abort_abort'] = 'Abort'; +cn['abort_abort'] = '中止'; +ct['abort_abort'] = '中止'; +de['abort_abort'] = 'Abbrechen'; +es['abort_abort'] = 'Abandonar'; +fr['abort_abort'] = 'Abandonner'; +pt['abort_abort'] = 'Abandonar'; +ko['abort_abort'] = '종료'; + +en['forceAbort_message'] = 'The maximum waiting time has been reached. Your session has ended.'; +cn['forceAbort_message'] = '已达到最长等待时间,您的会话已结束。'; +ct['forceAbort_message'] = '已達到最長等待時間,您的會話已結束。'; +de['forceAbort_message'] = 'Die maximale Wartezeit wurde erreicht. Ihre Sitzung wurde beendet.'; +es['forceAbort_message'] = 'Se ha alcanzado el tiempo máximo de espera. Su sesión ha finalizado.'; +fr['forceAbort_message'] = "Le temps d'attente maximum a été atteint. Votre session est terminée."; +pt['forceAbort_message'] = 'O tempo máximo de espera foi atingido. Sua sessão foi encerrada.'; +ko['forceAbort_message'] = '최대 대기 시간에 도달했습니다. 세션이 종료되었습니다.'; + +en['forceAbort_ok'] = 'OK'; +cn['forceAbort_ok'] = '确定'; +ct['forceAbort_ok'] = '確定'; +de['forceAbort_ok'] = 'OK'; +es['forceAbort_ok'] = 'Aceptar'; +fr['forceAbort_ok'] = 'OK'; +pt['forceAbort_ok'] = 'OK'; +ko['forceAbort_ok'] = '확인'; + +// Lobby +en['lobby_header'] = 'Lobby Status'; +cn['lobby_header'] = '大厅状态'; +ct['lobby_header'] = '大廳狀態'; +de['lobby_header'] = 'Lobby-Status'; +es['lobby_header'] = 'Estado de la sala'; +fr['lobby_header'] = 'État du lobby'; +pt['lobby_header'] = 'Estado do lobby'; +ko['lobby_header'] = '로비 상태'; + +en['lobby_headerWaiting'] = 'Waiting (mm:ss)'; +cn['lobby_headerWaiting'] = '等待时间(分:秒)'; +ct['lobby_headerWaiting'] = '等待時間(分:秒)'; +de['lobby_headerWaiting'] = 'Wartezeit (mm:ss)'; +es['lobby_headerWaiting'] = 'Espera (mm:ss)'; +fr['lobby_headerWaiting'] = 'Attente (mm:ss)'; +pt['lobby_headerWaiting'] = 'Aguardando (mm:ss)'; +ko['lobby_headerWaiting'] = '대기 시간 (mm:ss)'; + +en['lobby_fisherMissing'] = 'Fisher missing'; +cn['lobby_fisherMissing'] = '渔人缺席'; +ct['lobby_fisherMissing'] = '漁人缺席'; +de['lobby_fisherMissing'] = 'Fischer fehlt'; +es['lobby_fisherMissing'] = 'Pescador faltante'; +fr['lobby_fisherMissing'] = 'Pêcheur manquant'; +pt['lobby_fisherMissing'] = 'Pescador ausente'; +ko['lobby_fisherMissing'] = '플레이어 없음'; + +en['lobby_fisherReady'] = 'Fisher ready and waiting'; +cn['lobby_fisherReady'] = '渔人已准备好,等待中'; +ct['lobby_fisherReady'] = '漁人已準備好,等待中'; +de['lobby_fisherReady'] = 'Fischer bereit und wartend'; +es['lobby_fisherReady'] = 'Pescador listo y esperando'; +fr['lobby_fisherReady'] = 'Pêcheur prêt et en attente'; +pt['lobby_fisherReady'] = 'Pescador pronto e aguardando'; +ko['lobby_fisherReady'] = '플레이어 준비 완료, 대기 중'; + +en['lobby_fisherReading'] = 'Fisher reading rules'; +cn['lobby_fisherReading'] = '渔人正在阅读规则'; +ct['lobby_fisherReading'] = '漁人正在閱讀規則'; +de['lobby_fisherReading'] = 'Fischer liest Regeln'; +es['lobby_fisherReading'] = 'Pescador leyendo las reglas'; +fr['lobby_fisherReading'] = 'Pêcheur lit les règles'; +pt['lobby_fisherReading'] = 'Pescador lendo as regras'; +ko['lobby_fisherReading'] = '플레이어 규칙 읽는 중'; + // End report en['end_over'] = 'This simulation is over.'; en['end_over'] = 'This game is over.'; // RMK diff --git a/public/js/microworld.js b/public/js/microworld.js index 747a5d4..1b06b20 100644 --- a/public/js/microworld.js +++ b/public/js/microworld.js @@ -48,7 +48,22 @@ function readyTooltips() { $('#catch-intention-seasons-tooltip').tooltip(); $('#catch-intent-dialog-duration-tooltip').tooltip(); $('#redirect-url-tooltip').tooltip(); - $('#profit-columns-tooltip').tooltip(); + $('#read-rules-timeout-tooltip').tooltip(); + $('#lobby-wait-timeout-tooltip').tooltip(); + $('#prompt-timeout-tooltip').tooltip(); + $('#max-timeouts-tooltip').tooltip(); + $('#profit-season-tooltip').tooltip(); + $('#profit-total-tooltip').tooltip(); + $('#profit-gap-tooltip').tooltip(); + $('#fisher-classes-tooltip').tooltip(); + $('#fisher-class-names-tooltip').tooltip(); + $('#fisher-class-counts-tooltip').tooltip(); + $('#fisher-class-emojis-tooltip').tooltip(); + $('#fisher-advantage-tooltip').tooltip(); + $('#clean-abort-tooltip').tooltip(); + $('#fish-value-pay-gap-tooltip').tooltip(); + $('#advantage-emoji-tooltip').tooltip(); + $('#disadvantage-emoji-tooltip').tooltip(); } function changeBotRowVisibility() { @@ -62,11 +77,11 @@ function changeBotRowVisibility() { if (numHumans > numFishers) numHumans = numFishers; for (var i = 1; i <= numFishers - numHumans; i++) { - $('#bot-' + i + '-row').removeClass('collapse'); + $('.bot-' + i + '-col').removeClass('hide'); } for (var i = numFishers - numHumans + 1; i <= maxBot; i++) { - $('#bot-' + i + '-row').addClass('collapse'); + $('.bot-' + i + '-col').addClass('hide'); } } @@ -148,6 +163,22 @@ function changeAttemptsSecondUniformity() { } } +function updateBotClassDropdowns() { + var classNames = parseFisherClassNames($('#fisher-class-names').val()); + for (var i = 1; i <= maxBot; i++) { + var $select = $('#bot-' + i + '-class'); + var currentVal = $select.val(); + $select.empty(); + for (var j = 0; j < classNames.length; j++) { + $select.append($('