"luxon": "^1.28.0",
"socket.io": "^4.5.1",
"sqlite3": "^5.0.6",
- "uuid": "^8.3.2"
+ "uuid": "^8.3.2",
+ "validator": "^13.7.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/lodash": "^4.14.182",
"@types/luxon": "^2.3.2",
"@types/uuid": "^8.3.4",
+ "@types/validator": "^13.7.2",
"nodemon": "^2.0.16",
"ts-node": "^10.7.0",
"tsconfig-paths": "^4.0.0",
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
+ "node_modules/@types/validator": {
+ "version": "13.7.2",
+ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.2.tgz",
+ "integrity": "sha512-KFcchQ3h0OPQgFirBRPZr5F/sVjxZsOrQHedj3zi8AH3Zv/hOLx2OLR4hxR5HcfoU+33n69ZuOfzthKVdMoTiw==",
+ "dev": true
+ },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
+ "node_modules/validator": {
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
+ "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
+ "@types/validator": {
+ "version": "13.7.2",
+ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.2.tgz",
+ "integrity": "sha512-KFcchQ3h0OPQgFirBRPZr5F/sVjxZsOrQHedj3zi8AH3Zv/hOLx2OLR4hxR5HcfoU+33n69ZuOfzthKVdMoTiw==",
+ "dev": true
+ },
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
+ "validator": {
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
+ "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
+ },
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"private": true,
"scripts": {
"dev": "npx nodemon src/api.ts",
- "migrate": "npx ts-node --",
+ "migrate": "npx ts-node --",
"setup:rebels": "npx ts-node scripts/generate-cities.ts",
"setup": "npm run setup:rebels",
- "migration": "npx ts-node migrations/"
+ "migration": "npx ts-node migrations/"
},
"dependencies": {
"@bull-board/api": "^3.11.0",
"luxon": "^1.28.0",
"socket.io": "^4.5.1",
"sqlite3": "^5.0.6",
- "uuid": "^8.3.2"
+ "uuid": "^8.3.2",
+ "validator": "^13.7.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/lodash": "^4.14.182",
"@types/luxon": "^2.3.2",
"@types/uuid": "^8.3.4",
+ "@types/validator": "^13.7.2",
"nodemon": "^2.0.16",
"ts-node": "^10.7.0",
"tsconfig-paths": "^4.0.0",
th, td {
padding: 0.5rem;
}
+l
p, form, ul, ol {
line-height: 1.3rem;
margin: 0 2rem 2rem 2rem;
margin-right: 5px;
}
+a {
+ font-weight: bold;
+ color: #fff;
+ text-decoration: none;
+}
+a::before {
+ content: '\27EA';
+}
+a::after {
+ content: '\27EB';
+}
button, .btn {
border: solid 1px var(--border);
background-color: #183238;
text-align: center;
font-weight: bold;
min-width: 150px;
+ display: inline-block;
+}
+a.btn::before {
+ content: '';
+ clear: both;
}
button::after, .btn::after {
content: '\27EB';
button.success:active, .btn.success:active, button.success:hover, .btn.success:hover {
background-color: #1e4c1a;
}
+button.danger:active, .btn.danger:active, button.danger:hover, .btn.danger:hover {
+ background-color: #601f1f;
+}
+a.close::before, a.close::after{
+ content: '';
+}
input[type="text"], input[type="password"], input[type="number"] {
border: solid 1px var(--border);
padding: 5px;
}
-a {
- font-weight: bold;
- color: #fff;
- text-decoration: none;
-}
-a::before {
- content: '\27EA';
-}
-a::after {
- content: '\27EB';
-}
footer {
text-align: center;
border-top: solid 1px var(--border);
.text-center {
text-align: center;
}
+.progress-bar {
+ border: solid 1px #fff;
+ text-align: center;
+ color: #fff;
+ width: 100%;
+ min-width: 100px;
+}
/** CUSTOMIZATIONS **/
form > div {
margin-bottom: 0;
}
-.progress-bar {
- border: solid 1px #fff;
- text-align: center;
- color: #fff;
- width: 100%;
-}
.unread td{
background-color: #373737;
}
import { HttpServer } from './lib/server';
import * as config from './config';
import { AccountRepository } from './repository/accounts';
-import { CityRepository } from './repository/city';
+import { City, CityRepository } from './repository/city';
import { MailRepository } from './repository/mail';
import {BadInputError, ERROR_CODE, NotFoundError} from './errors';
import { renderKingomOverview } from './render/kingdom-overview';
import {renderMailroom, renderMessage} from './render/mail';
import {topbar} from './render/topbar';
import {renderPublicChatMessage} from './render/chat-message';
+import validator from 'validator';
const server = new HttpServer(config.API_PORT);
return;
});
+server.post<{params: {queueId: string}}, void>('/construction/:queueId/cancel', async req => {
+ const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+ const city = await cityRepo.getUsersCity(acct.id);
+
+ if(!validator.isUUID(req.params.queueId)) {
+ throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
+ }
+
+ // validate that this is an actual queue
+ const queue = await cityRepo.buildQueue.FindOne({owner: city.owner, id: req.params.queueId});
+
+ if(!queue) {
+ throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
+ }
+
+ const [, building] = await Promise.all([
+ cityRepo.buildQueue.Delete({
+ owner: city.owner,
+ id: req.params.queueId
+ }),
+ cityRepo.buildingRepository.findBySlug(queue.building_type)
+ ]);
+
+ // now that it's deleted we can give the player back some percentage
+ // of resources based on how close they are to completion.
+ const diff = (queue.due - Date.now()) / (queue.due - queue.created);
+ // force a 20% loss minimum
+ const finalDiff = diff < 0.2 ? 0.2 : diff;
+
+ const costReturn: Partial<City> = {
+ id: city.id,
+ credits: city.credits + Math.floor(building.credits * queue.amount * diff),
+ alloys: city.alloys + Math.floor(building.alloys * queue.amount * diff),
+ energy: city.energy + Math.floor(building.energy * queue.amount * diff),
+ usedSpace: city.usedSpace - (building.land * queue.amount)
+ };
+
+ console.log('update', costReturn)
+
+ await cityRepo.save(costReturn);
+
+}, 'reload-construction-queue');
+
+server.post<{params: {queueId: string}}, void>('/training/:queueId/cancel', async req => {
+ const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
+ const city = await cityRepo.getUsersCity(acct.id);
+
+ if(!validator.isUUID(req.params.queueId)) {
+ throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
+ }
+
+ const queue = await cityRepo.unitTrainigQueue.FindOne({owner: city.owner, id: req.params.queueId});
+ if(!queue) {
+ throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
+ }
+
+ const [, unit] = await Promise.all([
+ cityRepo.unitTrainigQueue.Delete({
+ owner: city.owner,
+ id: req.params.queueId
+ }),
+ cityRepo.unitRepository.FindOne({slug: queue.unit_type})
+ ]);
+
+ // now that it's deleted we can give the player back some percentage
+ // of resources based on how close they are to completion.
+ const diff = (queue.due - Date.now()) / (queue.due - queue.created);
+ // force a 20% loss minimum
+ const finalDiff = diff < 0.2 ? 0.2 : diff;
+
+ const costReturn: Partial<City> = {
+ id: city.id,
+ credits: city.credits + Math.floor(unit.credits * queue.amount * diff),
+ food: city.food + Math.floor(unit.food * queue.amount * diff),
+ population: city.population + Math.floor(unit.population * queue.amount),
+ soldiers: city.soldiers + Math.floor(unit.soldiers * queue.amount),
+ attackers: city.attackers + Math.floor(unit.attackers * queue.amount),
+ defenders: city.defenders + Math.floor(unit.attackers * queue.amount)
+ };
+
+ console.log('update', costReturn)
+
+ await cityRepo.save(costReturn);
+
+}, 'reload-unit-training');
+
server.get<void, string>('/server-stats', async req => {
const date = new Date();
return `
NO_CITY: 2000,
INSUFFICIENT_RESOURCE: 3000,
INVALID_BUILDING: 4000,
+ INVALID_BUILD_QUEUE: 4100,
INVALID_AMOUNT: 6000,
INVALID_UNIT: 5000,
DUPLICATE_CACHE_KEY: 900,
import { Building } from '../repository/buildings';
import _ from "lodash";
-function progressBar(current, max): string {
+const emptyBuilding: BuildQueue = {
+ id: '',
+ owner: '',
+ amount: 0,
+ created: 0,
+ due: 0,
+ building_type: 'empty',
+};
+
+function progressBar(current: number, max: number): string {
const percent = Math.ceil((current/max) * 100);
return `
<div class="progress-bar construction" style="background: background: var(--green-bg);
export function renderLandDevelopment(city: CityWithLocation, buildings: Building[], buildQueues: BuildQueue[]): string {
const freeSpace = city.totalSpace - city.usedSpace;
+ const sortedBuildQueues = buildQueues.sort((a, b) => {
+ return a.due - b.due;
+ });
+
let html = `
<div hx-trigger="reload-construction-queue, every 600s" hx-get="/poll/construction">
<h2 data-augmented-ui="tl-clip bl-clip none">Construction</h2>
`;
const quickFindBuilding = _.keyBy(buildings, 'slug');
+ const finalBuildQueues = sortedBuildQueues;
+
+ if (finalBuildQueues.length < city.max_construction_queue) {
+ while(finalBuildQueues.length < city.max_construction_queue) {
+ finalBuildQueues.push(emptyBuilding);
+ }
+ }
+
const queues = `
<hr>
<h2 data-augmented-ui="tl-clip bl-clip none">Build Queues</h2>
<table>
<tr>
- <th>Building</th>
- <th>Amount Expected</th>
- <th>Progress</th>
+ <th align="left">Building</th>
+ <th align="left">Amount Expected</th>
+ <th colspan="2">Progress</th>
</tr>
- ${buildQueues.sort((a, b) => {
- return a.due - b.due;
- }).map(queue => {
+ ${sortedBuildQueues.map(queue => {
+ if(queue.building_type === 'empty') {
+ return `
+ <tr>
+ <td colspan="4">You have sufficient capacity to build something.</td>
+ </tr>
+ `
+ }
+ else {
const now = Date.now() - queue.created;
const duration = queue.due - queue.created;
<td>${quickFindBuilding[queue.building_type].display}</td>
<td>${queue.amount}</td>
<td>${progressBar(now, duration)}</td>
+ <td width="50">
+ <a href="#" hx-post="/construction/${queue.id}/cancel" hx-trigger="click" class="danger-text close" title="Cancel Construction">×</a>
+ </td>
</tr>
`;
+ }
}).join("\n")}
</table>
</div>
import { Unit } from "../repository/unit";
import { DateTime } from "luxon";
+const emptyQueue: UnitTrainingQueueWithName = {
+ display: '',
+ id: '',
+ owner: '',
+ amount: 0,
+ created: 0,
+ due: 0,
+ unit_type: 'empty'
+}
+
function progressBar(current: number, max: number): string {
const percent = Math.ceil((current/max) * 100);
return `
export function renderUnitTraining(city: CityWithLocation, units: Unit[], trainingQueues: UnitTrainingQueueWithName[]): string {
const unit = _.keyBy(units, 'slug');
+ const sortedTrainingQueue = trainingQueues.sort((a, b) => {
+ return a.due - b.due;
+ });
let html = `
<div hx-trigger="reload-unit-training, every 600s" hx-get="/poll/unit-training">
<h2 data-augmented-ui="tl-clip bl-clip none">Unit Training</h2>
</table>
`;
+ const finalTrainingQueue = sortedTrainingQueue;
+ if(finalTrainingQueue.length < city.max_training_queue) {
+ while(finalTrainingQueue.length < city.max_construction_queue) {
+ finalTrainingQueue.push(emptyQueue);
+ }
+ }
+
const queues = `
<hr>
<h2 data-augmented-ui="tl-clip bl-clip none">Training Queues</h2>
<table>
<tr>
- <th>Unit Type</th>
- <th>Amount Expected</th>
- <th>Progress</th>
+ <th align="left">Unit Type</th>
+ <th align="left">Amount Expected</th>
+ <th colspan="2">Progress</th>
</tr>
- ${trainingQueues.sort((a, b) => {
- return a.due - b.due;
- }).map(queue => {
- const created = DateTime.fromMillis(queue.created);
- const due = DateTime.fromMillis(queue.due);
- const now = Date.now() - queue.created;
- const duration = queue.due - queue.created;
+ ${sortedTrainingQueue.map(queue => {
+ if(queue.unit_type === 'empty') {
+ return `<tr>
+ <td colspan="4">You have sufficient queue capacity to train more units.</td>
+ </tr>`;
+ }
+ else {
+ const created = DateTime.fromMillis(queue.created);
+ const due = DateTime.fromMillis(queue.due);
+ const now = Date.now() - queue.created;
+ const duration = queue.due - queue.created;
- return `
- <tr>
- <td>${queue.display}</td>
- <td>${queue.amount}</td>
- <td>
- <span title="${Math.ceil(due.diff(created).as('minutes')).toLocaleString()} minutes remaining">
- ${progressBar(now, duration)}<br>
- </span>
- </td>
- </tr>
- `;
+ return `
+ <tr>
+ <td>${queue.display}</td>
+ <td>${queue.amount}</td>
+ <td>${progressBar(now, duration)}</td>
+ <td width="50">
+ <a href="#" hx-post="/training/${queue.id}/cancel" hx-trigger="click" class="danger-text close" title="Cancel Unit Training">×</a>
+ </td>
+ </tr>
+ `;
+ }
}).join("\n")}
</table>
</div>
return sample.sector_id;
}
- async save(city: City) {
+ async save(city: Partial<City>) {
+ if(!city.id) {
+ throw new Error('Unknown city to save');
+ }
const fieldsToSave = [
'totalSpace', 'usedSpace', 'credits', 'alloys', 'energy', 'food',
'poulation', 'soldiers', 'attackers', 'defenders', 'sp_attackers', 'sp_defenders',
// validate that they have enough empty construction queues
const concurrentConstruction = await this.buildQueue.list(city.owner);
if(concurrentConstruction.length >= city.max_construction_queue) {
- throw new InsufficientResourceError('Training queues', concurrentConstruction.length + 1, city.max_construction_queue);
+ throw new InsufficientResourceError('Construction queues', concurrentConstruction.length + 1, city.max_construction_queue);
}
city.usedSpace += (building.land * amount);